tbr-deal-finder 0.2.0__py3-none-any.whl → 0.3.1__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/__init__.py +1 -5
- tbr_deal_finder/__main__.py +7 -0
- tbr_deal_finder/book.py +28 -8
- tbr_deal_finder/cli.py +15 -28
- tbr_deal_finder/config.py +3 -3
- tbr_deal_finder/desktop_updater.py +147 -0
- tbr_deal_finder/gui/__init__.py +0 -0
- tbr_deal_finder/gui/main.py +725 -0
- tbr_deal_finder/gui/pages/__init__.py +1 -0
- tbr_deal_finder/gui/pages/all_books.py +93 -0
- tbr_deal_finder/gui/pages/all_deals.py +63 -0
- tbr_deal_finder/gui/pages/base_book_page.py +291 -0
- tbr_deal_finder/gui/pages/book_details.py +604 -0
- tbr_deal_finder/gui/pages/latest_deals.py +370 -0
- tbr_deal_finder/gui/pages/settings.py +389 -0
- tbr_deal_finder/migrations.py +26 -0
- tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
- tbr_deal_finder/retailer/amazon.py +60 -9
- tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
- tbr_deal_finder/retailer/audible.py +2 -1
- tbr_deal_finder/retailer/chirp.py +55 -11
- tbr_deal_finder/retailer/kindle.py +68 -44
- tbr_deal_finder/retailer/librofm.py +58 -21
- tbr_deal_finder/retailer/models.py +31 -1
- tbr_deal_finder/retailer_deal.py +62 -21
- tbr_deal_finder/tracked_books.py +76 -8
- tbr_deal_finder/utils.py +64 -2
- tbr_deal_finder/version_check.py +40 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +19 -88
- tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
- tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -3,13 +3,12 @@ import json
|
|
3
3
|
import os
|
4
4
|
from datetime import datetime, timedelta
|
5
5
|
from textwrap import dedent
|
6
|
+
from typing import Union
|
6
7
|
|
7
|
-
import aiohttp
|
8
8
|
import click
|
9
9
|
|
10
|
-
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 AioHttpSession, Retailer
|
11
|
+
from tbr_deal_finder.retailer.models import AioHttpSession, Retailer, GuiAuthContext
|
13
12
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
14
13
|
from tbr_deal_finder.utils import currency_to_float, echo_err
|
15
14
|
|
@@ -52,17 +51,21 @@ class Chirp(AioHttpSession, Retailer):
|
|
52
51
|
else:
|
53
52
|
return {}
|
54
53
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
with open(auth_path, "r") as f:
|
54
|
+
def user_is_authed(self) -> bool:
|
55
|
+
if os.path.exists(self.auth_path):
|
56
|
+
with open(self.auth_path, "r") as f:
|
59
57
|
auth_info = json.load(f)
|
60
58
|
if auth_info:
|
61
59
|
token_created_at = datetime.fromtimestamp(auth_info["created_at"])
|
62
|
-
max_token_age = datetime.now() - timedelta(days=
|
60
|
+
max_token_age = datetime.now() - timedelta(days=7)
|
63
61
|
if token_created_at > max_token_age:
|
64
62
|
self.auth_token = auth_info["data"]["signIn"]["user"]["token"]
|
65
|
-
return
|
63
|
+
return True
|
64
|
+
return False
|
65
|
+
|
66
|
+
async def set_auth(self):
|
67
|
+
if self.user_is_authed():
|
68
|
+
return
|
66
69
|
|
67
70
|
response = await self.make_request(
|
68
71
|
"POST",
|
@@ -82,12 +85,53 @@ class Chirp(AioHttpSession, Retailer):
|
|
82
85
|
self.auth_token = response["data"]["signIn"]["user"]["token"]
|
83
86
|
|
84
87
|
response["created_at"] = datetime.now().timestamp()
|
85
|
-
with open(auth_path, "w") as f:
|
88
|
+
with open(self.auth_path, "w") as f:
|
89
|
+
json.dump(response, f)
|
90
|
+
|
91
|
+
@property
|
92
|
+
def gui_auth_context(self) -> GuiAuthContext:
|
93
|
+
return GuiAuthContext(
|
94
|
+
title="Login to Chirp",
|
95
|
+
fields=[
|
96
|
+
{"name": "email", "label": "Email", "type": "email"},
|
97
|
+
{"name": "password", "label": "Password", "type": "password"}
|
98
|
+
]
|
99
|
+
)
|
100
|
+
|
101
|
+
async def gui_auth(self, form_data: dict) -> bool:
|
102
|
+
response = await self.make_request(
|
103
|
+
"POST",
|
104
|
+
json={
|
105
|
+
"query": "mutation signIn($email: String!, $password: String!) { signIn(email: $email, password: $password) { user { id token webToken email } } }",
|
106
|
+
"variables": {
|
107
|
+
"email": form_data["email"],
|
108
|
+
"password": form_data["password"],
|
109
|
+
}
|
110
|
+
}
|
111
|
+
)
|
112
|
+
if not response:
|
113
|
+
return False
|
114
|
+
|
115
|
+
auth_token = response.get("data", {})
|
116
|
+
for key in ["signIn", "user", "token"]:
|
117
|
+
if key not in auth_token:
|
118
|
+
return False
|
119
|
+
auth_token = auth_token[key]
|
120
|
+
|
121
|
+
if not auth_token:
|
122
|
+
return False
|
123
|
+
|
124
|
+
# Set token for future requests during the current execution
|
125
|
+
self.auth_token = auth_token
|
126
|
+
|
127
|
+
response["created_at"] = datetime.now().timestamp()
|
128
|
+
with open(self.auth_path, "w") as f:
|
86
129
|
json.dump(response, f)
|
130
|
+
return True
|
87
131
|
|
88
132
|
async def get_book(
|
89
133
|
self, target: Book, semaphore: asyncio.Semaphore
|
90
|
-
) -> Book:
|
134
|
+
) -> Union[Book, None]:
|
91
135
|
title = target.title
|
92
136
|
async with semaphore:
|
93
137
|
session = await self._get_session()
|
@@ -1,13 +1,17 @@
|
|
1
1
|
import asyncio
|
2
|
-
import
|
2
|
+
import json
|
3
|
+
from typing import Union
|
3
4
|
|
4
5
|
from tbr_deal_finder.config import Config
|
5
|
-
from tbr_deal_finder.retailer.amazon import Amazon
|
6
|
+
from tbr_deal_finder.retailer.amazon import Amazon, AUTH_PATH
|
6
7
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_title, get_normalized_authors, is_matching_authors
|
7
8
|
|
8
9
|
|
9
10
|
class Kindle(Amazon):
|
10
11
|
|
12
|
+
def __init__(self):
|
13
|
+
self._headers = {}
|
14
|
+
|
11
15
|
@property
|
12
16
|
def name(self) -> str:
|
13
17
|
return "Kindle"
|
@@ -16,9 +20,32 @@ class Kindle(Amazon):
|
|
16
20
|
def format(self) -> BookFormat:
|
17
21
|
return BookFormat.EBOOK
|
18
22
|
|
23
|
+
@property
|
24
|
+
def max_concurrency(self) -> int:
|
25
|
+
return 3
|
26
|
+
|
19
27
|
def _get_base_url(self) -> str:
|
20
28
|
return f"https://www.amazon.{self._auth.locale.domain}"
|
21
29
|
|
30
|
+
def _get_read_base_url(self) -> str:
|
31
|
+
return f"https://read.amazon.{self._auth.locale.domain}"
|
32
|
+
|
33
|
+
async def set_auth(self):
|
34
|
+
await super().set_auth()
|
35
|
+
|
36
|
+
with open(AUTH_PATH, "r") as f:
|
37
|
+
auth_info = json.load(f)
|
38
|
+
|
39
|
+
cookies = auth_info["website_cookies"]
|
40
|
+
cookies["x-access-token"] = auth_info["access_token"]
|
41
|
+
|
42
|
+
self._headers = {
|
43
|
+
"User-Agent": "Mozilla/5.0 (Linux; Android 10; Kindle) AppleWebKit/537.3",
|
44
|
+
"Accept": "application/json, */*",
|
45
|
+
"Cookie": "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
46
|
+
}
|
47
|
+
|
48
|
+
|
22
49
|
async def get_book_asin(
|
23
50
|
self,
|
24
51
|
target: Book,
|
@@ -50,7 +77,7 @@ class Kindle(Amazon):
|
|
50
77
|
self,
|
51
78
|
target: Book,
|
52
79
|
semaphore: asyncio.Semaphore
|
53
|
-
) -> Book:
|
80
|
+
) -> Union[Book, None]:
|
54
81
|
target.exists = False
|
55
82
|
|
56
83
|
if not target.ebook_asin:
|
@@ -58,26 +85,34 @@ class Kindle(Amazon):
|
|
58
85
|
|
59
86
|
asin = target.ebook_asin
|
60
87
|
async with semaphore:
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
88
|
+
for i in range(10):
|
89
|
+
match = await self._client.get(
|
90
|
+
f"{self._get_base_url()}/api/bifrost/offers/batch/v1/{asin}?ref_=KindleDeepLinkOffers",
|
91
|
+
headers={"x-client-id": "kindle-android-deeplink"},
|
92
|
+
)
|
93
|
+
products = match.get("resources", [])
|
94
|
+
if not products:
|
95
|
+
await asyncio.sleep(1)
|
96
|
+
continue
|
68
97
|
|
69
|
-
|
70
|
-
|
71
|
-
|
98
|
+
actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
|
99
|
+
if not actions:
|
100
|
+
await asyncio.sleep(1)
|
101
|
+
continue
|
72
102
|
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
103
|
+
for action in actions:
|
104
|
+
if "printListPrice" in action["offer"]:
|
105
|
+
target.list_price = action["offer"]["printListPrice"]["value"]
|
106
|
+
target.current_price = action["offer"]["digitalPrice"]["value"]
|
107
|
+
target.exists = True
|
108
|
+
break
|
79
109
|
|
80
|
-
|
110
|
+
# The sleep is a pre-emptive backoff
|
111
|
+
# Concurrency is already low, but this endpoint loves to throttle
|
112
|
+
await asyncio.sleep(.25)
|
113
|
+
return target
|
114
|
+
|
115
|
+
return None
|
81
116
|
|
82
117
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
83
118
|
"""Not currently supported
|
@@ -90,44 +125,28 @@ class Kindle(Amazon):
|
|
90
125
|
return []
|
91
126
|
|
92
127
|
async def get_library(self, config: Config) -> list[Book]:
|
93
|
-
|
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 = []
|
128
|
+
books = []
|
108
129
|
pagination_token = 0
|
109
|
-
|
130
|
+
url = f"{self._get_read_base_url()}/kindle-library/search"
|
110
131
|
|
111
|
-
while
|
132
|
+
while True:
|
112
133
|
optional_params = {}
|
113
134
|
if pagination_token:
|
114
135
|
optional_params["paginationToken"] = pagination_token
|
115
136
|
|
116
137
|
response = await self._client.get(
|
117
|
-
|
138
|
+
url,
|
139
|
+
headers=self._headers,
|
118
140
|
query="",
|
119
141
|
libraryType="BOOKS",
|
120
142
|
sortType="recency",
|
121
143
|
resourceType="EBOOK",
|
122
|
-
querySize=
|
144
|
+
querySize=50,
|
123
145
|
**optional_params
|
124
146
|
)
|
125
147
|
|
126
|
-
if "paginationToken" in response:
|
127
|
-
total_pages = int(response["paginationToken"])
|
128
|
-
|
129
148
|
for book in response["itemsList"]:
|
130
|
-
|
149
|
+
books.append(
|
131
150
|
Book(
|
132
151
|
retailer=self.name,
|
133
152
|
title = book["title"],
|
@@ -138,4 +157,9 @@ class Kindle(Amazon):
|
|
138
157
|
)
|
139
158
|
)
|
140
159
|
|
141
|
-
|
160
|
+
if "paginationToken" in response:
|
161
|
+
pagination_token = int(response["paginationToken"])
|
162
|
+
else:
|
163
|
+
break
|
164
|
+
|
165
|
+
return books
|
@@ -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
|
6
7
|
|
7
8
|
import click
|
8
9
|
|
9
|
-
from tbr_deal_finder import TBR_DEALS_PATH
|
10
10
|
from tbr_deal_finder.config import Config
|
11
|
-
from tbr_deal_finder.retailer.models import AioHttpSession, Retailer
|
11
|
+
from tbr_deal_finder.retailer.models import AioHttpSession, Retailer, GuiAuthContext
|
12
12
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors, get_normalized_title
|
13
|
-
from tbr_deal_finder.utils import currency_to_float
|
13
|
+
from tbr_deal_finder.utils import currency_to_float, echo_err
|
14
14
|
|
15
15
|
|
16
16
|
class LibroFM(AioHttpSession, Retailer):
|
@@ -57,16 +57,21 @@ class LibroFM(AioHttpSession, Retailer):
|
|
57
57
|
else:
|
58
58
|
return {}
|
59
59
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
with open(auth_path, "r") as f:
|
60
|
+
def user_is_authed(self) -> bool:
|
61
|
+
if os.path.exists(self.auth_path):
|
62
|
+
with open(self.auth_path, "r") as f:
|
64
63
|
auth_info = json.load(f)
|
65
64
|
token_created_at = datetime.fromtimestamp(auth_info["created_at"])
|
66
|
-
max_token_age = datetime.now() - timedelta(days=
|
65
|
+
max_token_age = datetime.now() - timedelta(days=7)
|
67
66
|
if token_created_at > max_token_age:
|
68
67
|
self.auth_token = auth_info["access_token"]
|
69
|
-
return
|
68
|
+
return True
|
69
|
+
|
70
|
+
return False
|
71
|
+
|
72
|
+
async def set_auth(self):
|
73
|
+
if self.user_is_authed():
|
74
|
+
return
|
70
75
|
|
71
76
|
response = await self.make_request(
|
72
77
|
"/oauth/token",
|
@@ -77,10 +82,42 @@ class LibroFM(AioHttpSession, Retailer):
|
|
77
82
|
"password": click.prompt("Libro FM Password", hide_input=True),
|
78
83
|
}
|
79
84
|
)
|
85
|
+
if "access_token" not in response:
|
86
|
+
echo_err("Login failed. Try again.")
|
87
|
+
await self.set_auth()
|
88
|
+
|
80
89
|
self.auth_token = response["access_token"]
|
81
|
-
with open(auth_path, "w") as f:
|
90
|
+
with open(self.auth_path, "w") as f:
|
82
91
|
json.dump(response, f)
|
83
92
|
|
93
|
+
@property
|
94
|
+
def gui_auth_context(self) -> GuiAuthContext:
|
95
|
+
return GuiAuthContext(
|
96
|
+
title="Login to Libro.FM",
|
97
|
+
fields=[
|
98
|
+
{"name": "username", "label": "Username", "type": "text"},
|
99
|
+
{"name": "password", "label": "Password", "type": "password"}
|
100
|
+
]
|
101
|
+
)
|
102
|
+
|
103
|
+
async def gui_auth(self, form_data: dict) -> bool:
|
104
|
+
response = await self.make_request(
|
105
|
+
"/oauth/token",
|
106
|
+
"POST",
|
107
|
+
json={
|
108
|
+
"grant_type": "password",
|
109
|
+
"username": form_data["username"],
|
110
|
+
"password": form_data["password"],
|
111
|
+
}
|
112
|
+
)
|
113
|
+
if "access_token" not in response:
|
114
|
+
return False
|
115
|
+
|
116
|
+
self.auth_token = response["access_token"]
|
117
|
+
with open(self.auth_path, "w") as f:
|
118
|
+
json.dump(response, f)
|
119
|
+
return True
|
120
|
+
|
84
121
|
async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Book:
|
85
122
|
# runtime isn't used but get_book_isbn must follow the get_book method signature.
|
86
123
|
|
@@ -111,24 +148,24 @@ class LibroFM(AioHttpSession, Retailer):
|
|
111
148
|
|
112
149
|
async def get_book(
|
113
150
|
self, target: Book, semaphore: asyncio.Semaphore
|
114
|
-
) -> Book:
|
151
|
+
) -> Union[Book, None]:
|
115
152
|
if not target.audiobook_isbn:
|
116
153
|
target.exists = False
|
117
154
|
return target
|
118
155
|
|
119
156
|
async with semaphore:
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
157
|
+
for _ in range(10):
|
158
|
+
response = await self.make_request(
|
159
|
+
f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
|
160
|
+
"GET"
|
161
|
+
)
|
124
162
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
163
|
+
if response:
|
164
|
+
target.list_price = target.audiobook_list_price
|
165
|
+
target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
|
166
|
+
return target
|
129
167
|
|
130
|
-
|
131
|
-
return target
|
168
|
+
return None
|
132
169
|
|
133
170
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
134
171
|
wishlist_books = []
|
@@ -1,10 +1,23 @@
|
|
1
1
|
import abc
|
2
2
|
import asyncio
|
3
|
+
import dataclasses
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional, Union
|
3
6
|
|
4
7
|
import aiohttp
|
5
8
|
|
6
9
|
from tbr_deal_finder.book import Book, BookFormat
|
7
10
|
from tbr_deal_finder.config import Config
|
11
|
+
from tbr_deal_finder.utils import get_data_dir
|
12
|
+
|
13
|
+
|
14
|
+
@dataclasses.dataclass
|
15
|
+
class GuiAuthContext:
|
16
|
+
title: str
|
17
|
+
fields: list[dict]
|
18
|
+
message: Optional[str] = None
|
19
|
+
user_copy_context: Optional[str] = None
|
20
|
+
pop_up_type: Optional[str] = "form"
|
8
21
|
|
9
22
|
|
10
23
|
class Retailer(abc.ABC):
|
@@ -26,12 +39,29 @@ class Retailer(abc.ABC):
|
|
26
39
|
"""
|
27
40
|
raise NotImplementedError
|
28
41
|
|
42
|
+
@property
|
43
|
+
def auth_path(self) -> Path:
|
44
|
+
name = self.name.replace(".", "").lower()
|
45
|
+
return get_data_dir().joinpath(f"{name}.json")
|
46
|
+
|
47
|
+
@property
|
48
|
+
def gui_auth_context(self) -> GuiAuthContext:
|
49
|
+
raise NotImplementedError
|
50
|
+
|
51
|
+
@property
|
52
|
+
def max_concurrency(self) -> int:
|
53
|
+
# The max number of simultaneous requests to send to this retailer
|
54
|
+
return 10
|
55
|
+
|
29
56
|
async def set_auth(self):
|
30
57
|
raise NotImplementedError
|
31
58
|
|
59
|
+
async def gui_auth(self, form_data: dict) -> bool:
|
60
|
+
raise NotImplementedError
|
61
|
+
|
32
62
|
async def get_book(
|
33
63
|
self, target: Book, semaphore: asyncio.Semaphore
|
34
|
-
) -> Book:
|
64
|
+
) -> Union[Book, None]:
|
35
65
|
"""Get book information from the retailer.
|
36
66
|
|
37
67
|
- Uses Audible's product API to fetch book details
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -8,10 +8,10 @@ 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.tracked_books import get_tbr_books
|
11
|
+
from tbr_deal_finder.tracked_books import get_tbr_books, get_unknown_books, set_unknown_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_info, echo_err, is_gui_env
|
15
15
|
|
16
16
|
|
17
17
|
def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
@@ -38,14 +38,6 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
38
38
|
else:
|
39
39
|
df_data.append(deal.dict())
|
40
40
|
|
41
|
-
# Any remaining values in active_deal_map mean that
|
42
|
-
# it wasn't found and should be marked for deletion
|
43
|
-
for deal in active_deal_map.values():
|
44
|
-
echo_warning(f"{str(deal)} is no longer active\n")
|
45
|
-
deal.timepoint = config.run_time
|
46
|
-
deal.deleted = True
|
47
|
-
df_data.append(deal.dict())
|
48
|
-
|
49
41
|
if df_data:
|
50
42
|
df = pd.DataFrame(df_data)
|
51
43
|
|
@@ -55,7 +47,12 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
55
47
|
db_conn.unregister("_df")
|
56
48
|
|
57
49
|
|
58
|
-
async def _get_books(
|
50
|
+
async def _get_books(
|
51
|
+
config,
|
52
|
+
retailer: Retailer,
|
53
|
+
books: list[Book],
|
54
|
+
ignored_deal_ids: set[str],
|
55
|
+
) -> tuple[list[Book], list[Book]]:
|
59
56
|
"""Get Books with limited concurrency.
|
60
57
|
|
61
58
|
- Creates semaphore to limit concurrent requests.
|
@@ -70,9 +67,9 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
70
67
|
Returns:
|
71
68
|
List of Book objects with updated pricing and availability
|
72
69
|
"""
|
73
|
-
semaphore = asyncio.Semaphore(
|
70
|
+
semaphore = asyncio.Semaphore(retailer.max_concurrency)
|
74
71
|
response = []
|
75
|
-
|
72
|
+
unknown_books = []
|
76
73
|
books = [copy.deepcopy(book) for book in books]
|
77
74
|
for book in books:
|
78
75
|
book.retailer = retailer.name
|
@@ -81,19 +78,32 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
81
78
|
tasks = [
|
82
79
|
retailer.get_book(book, semaphore)
|
83
80
|
for book in books
|
81
|
+
if book.deal_id not in ignored_deal_ids
|
84
82
|
]
|
85
|
-
|
83
|
+
|
84
|
+
if is_gui_env():
|
85
|
+
results = await asyncio.gather(*tasks)
|
86
|
+
else:
|
87
|
+
results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
|
88
|
+
|
86
89
|
for book in results:
|
87
|
-
if book
|
90
|
+
if not book:
|
91
|
+
"""Cases where we know the retailer has the book but it's not coming back.
|
92
|
+
We don't want to mark it as unknown it's more like we just got rate limited.
|
93
|
+
|
94
|
+
Kindle has been particularly bad about this.
|
95
|
+
"""
|
96
|
+
continue
|
97
|
+
elif book.exists:
|
88
98
|
response.append(book)
|
89
99
|
elif not book.exists:
|
90
|
-
|
100
|
+
unknown_books.append(book)
|
91
101
|
|
92
102
|
click.echo()
|
93
|
-
for book in
|
103
|
+
for book in unknown_books:
|
94
104
|
echo_info(f"{book.title} by {book.authors} not found")
|
95
105
|
|
96
|
-
return response
|
106
|
+
return response, unknown_books
|
97
107
|
|
98
108
|
|
99
109
|
def _apply_proper_list_prices(books: list[Book]):
|
@@ -151,7 +161,7 @@ def _get_retailer_relevant_tbr_books(
|
|
151
161
|
return response
|
152
162
|
|
153
163
|
|
154
|
-
async def
|
164
|
+
async def _get_latest_deals(config: Config):
|
155
165
|
"""
|
156
166
|
Fetches the latest book deals from all tracked retailers for the user's TBR list.
|
157
167
|
|
@@ -169,7 +179,10 @@ async def get_latest_deals(config: Config):
|
|
169
179
|
"""
|
170
180
|
|
171
181
|
books: list[Book] = []
|
182
|
+
unknown_books: list[Book] = []
|
172
183
|
tbr_books = await get_tbr_books(config)
|
184
|
+
ignore_books: list[Book] = get_unknown_books(config)
|
185
|
+
ignored_deal_ids: set[str] = {book.deal_id for book in ignore_books}
|
173
186
|
|
174
187
|
for retailer_str in config.tracked_retailers:
|
175
188
|
retailer = RETAILER_MAP[retailer_str]()
|
@@ -182,7 +195,14 @@ async def get_latest_deals(config: Config):
|
|
182
195
|
|
183
196
|
echo_info(f"Getting deals from {retailer.name}")
|
184
197
|
click.echo("\n---------------")
|
185
|
-
|
198
|
+
retailer_books, u_books = await _get_books(
|
199
|
+
config,
|
200
|
+
retailer,
|
201
|
+
relevant_tbr_books,
|
202
|
+
ignored_deal_ids
|
203
|
+
)
|
204
|
+
books.extend(retailer_books)
|
205
|
+
unknown_books.extend(u_books)
|
186
206
|
click.echo("---------------\n")
|
187
207
|
|
188
208
|
_apply_proper_list_prices(books)
|
@@ -190,7 +210,28 @@ async def get_latest_deals(config: Config):
|
|
190
210
|
books = [
|
191
211
|
book
|
192
212
|
for book in books
|
193
|
-
if book.current_price <= config.max_price and book.discount() >= config.min_discount
|
194
213
|
]
|
195
214
|
|
196
215
|
update_retailer_deal_table(config, books)
|
216
|
+
set_unknown_books(config, unknown_books)
|
217
|
+
|
218
|
+
|
219
|
+
async def get_latest_deals(config: Config) -> bool:
|
220
|
+
try:
|
221
|
+
await _get_latest_deals(config)
|
222
|
+
except Exception as e:
|
223
|
+
ran_successfully = False
|
224
|
+
details = f"Error getting deals: {e}"
|
225
|
+
echo_err(details)
|
226
|
+
else:
|
227
|
+
ran_successfully = True
|
228
|
+
details = ""
|
229
|
+
|
230
|
+
# Save execution results
|
231
|
+
db_conn = get_duckdb_conn()
|
232
|
+
db_conn.execute(
|
233
|
+
"INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
|
234
|
+
[config.run_time, ran_successfully, details]
|
235
|
+
)
|
236
|
+
|
237
|
+
return ran_successfully
|