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.
Files changed (34) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +28 -8
  4. tbr_deal_finder/cli.py +15 -28
  5. tbr_deal_finder/config.py +3 -3
  6. tbr_deal_finder/desktop_updater.py +147 -0
  7. tbr_deal_finder/gui/__init__.py +0 -0
  8. tbr_deal_finder/gui/main.py +725 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +93 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +291 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +370 -0
  15. tbr_deal_finder/gui/pages/settings.py +389 -0
  16. tbr_deal_finder/migrations.py +26 -0
  17. tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
  18. tbr_deal_finder/retailer/amazon.py +60 -9
  19. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  20. tbr_deal_finder/retailer/audible.py +2 -1
  21. tbr_deal_finder/retailer/chirp.py +55 -11
  22. tbr_deal_finder/retailer/kindle.py +68 -44
  23. tbr_deal_finder/retailer/librofm.py +58 -21
  24. tbr_deal_finder/retailer/models.py +31 -1
  25. tbr_deal_finder/retailer_deal.py +62 -21
  26. tbr_deal_finder/tracked_books.py +76 -8
  27. tbr_deal_finder/utils.py +64 -2
  28. tbr_deal_finder/version_check.py +40 -0
  29. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +19 -88
  30. tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
  31. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
  32. tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
  33. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
  34. {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
- async def set_auth(self):
56
- auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
57
- if os.path.exists(auth_path):
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=5)
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 readline # type: ignore
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
- 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
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
- actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
70
- if not actions:
71
- return target
98
+ actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
99
+ if not actions:
100
+ await asyncio.sleep(1)
101
+ continue
72
102
 
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
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
- return target
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
- """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 = []
128
+ books = []
108
129
  pagination_token = 0
109
- total_pages = 1
130
+ url = f"{self._get_read_base_url()}/kindle-library/search"
110
131
 
111
- while pagination_token < total_pages:
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
- "https://read.amazon.com/kindle-library/search",
138
+ url,
139
+ headers=self._headers,
118
140
  query="",
119
141
  libraryType="BOOKS",
120
142
  sortType="recency",
121
143
  resourceType="EBOOK",
122
- querySize=5,
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
- response.append(
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
- return response
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
- async def set_auth(self):
61
- auth_path = TBR_DEALS_PATH.joinpath("libro_fm.json")
62
- if os.path.exists(auth_path):
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=5)
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
- response = await self.make_request(
121
- f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
122
- "GET"
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
- if response:
126
- target.list_price = target.audiobook_list_price
127
- target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
128
- return target
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
- target.exists = False
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
@@ -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, echo_warning, echo_info
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(config, retailer: Retailer, books: list[Book]) -> list[Book]:
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(10)
70
+ semaphore = asyncio.Semaphore(retailer.max_concurrency)
74
71
  response = []
75
- unresolved_books = []
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
- results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
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.exists:
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
- unresolved_books.append(book)
100
+ unknown_books.append(book)
91
101
 
92
102
  click.echo()
93
- for book in unresolved_books:
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 get_latest_deals(config: Config):
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
- books.extend(await _get_books(config, retailer, relevant_tbr_books))
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