tbr-deal-finder 0.2.0__py3-none-any.whl → 0.3.2__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 +754 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +100 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +290 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +376 -0
  15. tbr_deal_finder/gui/pages/settings.py +390 -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 +69 -37
  26. tbr_deal_finder/tracked_books.py +76 -8
  27. tbr_deal_finder/utils.py +74 -3
  28. tbr_deal_finder/version_check.py +40 -0
  29. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/METADATA +19 -88
  30. tbr_deal_finder-0.3.2.dist-info/RECORD +38 -0
  31. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.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.2.dist-info}/WHEEL +0 -0
  34. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.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
@@ -2,16 +2,14 @@ import asyncio
2
2
  import copy
3
3
  from collections import defaultdict
4
4
 
5
- import click
6
5
  import pandas as pd
7
- from tqdm.asyncio import tqdm_asyncio
8
6
 
9
7
  from tbr_deal_finder.book import Book, get_active_deals, BookFormat
10
8
  from tbr_deal_finder.config import Config
11
- from tbr_deal_finder.tracked_books import get_tbr_books
9
+ from tbr_deal_finder.tracked_books import get_tbr_books, get_unknown_books, set_unknown_books
12
10
  from tbr_deal_finder.retailer import RETAILER_MAP
13
11
  from tbr_deal_finder.retailer.models import Retailer
14
- from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
12
+ from tbr_deal_finder.utils import get_duckdb_conn, echo_info, echo_err
15
13
 
16
14
 
17
15
  def update_retailer_deal_table(config: Config, new_deals: list[Book]):
@@ -38,14 +36,6 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
38
36
  else:
39
37
  df_data.append(deal.dict())
40
38
 
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
39
  if df_data:
50
40
  df = pd.DataFrame(df_data)
51
41
 
@@ -55,7 +45,12 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
55
45
  db_conn.unregister("_df")
56
46
 
57
47
 
58
- async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
48
+ async def _get_books(
49
+ config,
50
+ retailer: Retailer,
51
+ books: list[Book],
52
+ ignored_deal_ids: set[str],
53
+ ) -> tuple[list[Book], list[Book]]:
59
54
  """Get Books with limited concurrency.
60
55
 
61
56
  - Creates semaphore to limit concurrent requests.
@@ -70,9 +65,16 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
70
65
  Returns:
71
66
  List of Book objects with updated pricing and availability
72
67
  """
73
- semaphore = asyncio.Semaphore(10)
68
+
69
+ echo_info(f"Getting deals from {retailer.name}")
70
+ books = _get_retailer_relevant_tbr_books(
71
+ retailer,
72
+ books,
73
+ )
74
+
75
+ semaphore = asyncio.Semaphore(retailer.max_concurrency)
74
76
  response = []
75
- unresolved_books = []
77
+ unknown_books = []
76
78
  books = [copy.deepcopy(book) for book in books]
77
79
  for book in books:
78
80
  book.retailer = retailer.name
@@ -81,19 +83,26 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
81
83
  tasks = [
82
84
  retailer.get_book(book, semaphore)
83
85
  for book in books
86
+ if book.deal_id not in ignored_deal_ids
84
87
  ]
85
- results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
88
+
89
+ results = await asyncio.gather(*tasks)
86
90
  for book in results:
87
- if book.exists:
91
+ if not book:
92
+ """Cases where we know the retailer has the book but it's not coming back.
93
+ We don't want to mark it as unknown it's more like we just got rate limited.
94
+
95
+ Kindle has been particularly bad about this.
96
+ """
97
+ continue
98
+ elif book.exists:
88
99
  response.append(book)
89
100
  elif not book.exists:
90
- unresolved_books.append(book)
101
+ unknown_books.append(book)
91
102
 
92
- click.echo()
93
- for book in unresolved_books:
94
- echo_info(f"{book.title} by {book.authors} not found")
103
+ echo_info(f"Finished getting deals from {retailer.name}")
95
104
 
96
- return response
105
+ return response, unknown_books
97
106
 
98
107
 
99
108
  def _apply_proper_list_prices(books: list[Book]):
@@ -151,7 +160,7 @@ def _get_retailer_relevant_tbr_books(
151
160
  return response
152
161
 
153
162
 
154
- async def get_latest_deals(config: Config):
163
+ async def _get_latest_deals(config: Config):
155
164
  """
156
165
  Fetches the latest book deals from all tracked retailers for the user's TBR list.
157
166
 
@@ -169,28 +178,51 @@ async def get_latest_deals(config: Config):
169
178
  """
170
179
 
171
180
  books: list[Book] = []
181
+ unknown_books: list[Book] = []
172
182
  tbr_books = await get_tbr_books(config)
183
+ ignore_books: list[Book] = get_unknown_books(config)
184
+ ignored_deal_ids: set[str] = {book.deal_id for book in ignore_books}
173
185
 
186
+ tasks = []
174
187
  for retailer_str in config.tracked_retailers:
175
188
  retailer = RETAILER_MAP[retailer_str]()
176
189
  await retailer.set_auth()
177
190
 
178
- relevant_tbr_books = _get_retailer_relevant_tbr_books(
179
- retailer,
180
- tbr_books,
191
+ tasks.append(
192
+ _get_books(
193
+ config,
194
+ retailer,
195
+ tbr_books,
196
+ ignored_deal_ids
197
+ )
181
198
  )
182
199
 
183
- echo_info(f"Getting deals from {retailer.name}")
184
- click.echo("\n---------------")
185
- books.extend(await _get_books(config, retailer, relevant_tbr_books))
186
- click.echo("---------------\n")
200
+ results = await asyncio.gather(*tasks)
201
+ for retailer_books, u_books in results:
202
+ books.extend(retailer_books)
203
+ unknown_books.extend(u_books)
187
204
 
188
205
  _apply_proper_list_prices(books)
189
-
190
- books = [
191
- book
192
- for book in books
193
- if book.current_price <= config.max_price and book.discount() >= config.min_discount
194
- ]
195
-
196
206
  update_retailer_deal_table(config, books)
207
+ set_unknown_books(config, unknown_books)
208
+
209
+
210
+ async def get_latest_deals(config: Config) -> bool:
211
+ try:
212
+ await _get_latest_deals(config)
213
+ except Exception as e:
214
+ ran_successfully = False
215
+ details = f"Error getting deals: {e}"
216
+ echo_err(details)
217
+ else:
218
+ ran_successfully = True
219
+ details = ""
220
+
221
+ # Save execution results
222
+ db_conn = get_duckdb_conn()
223
+ db_conn.execute(
224
+ "INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
225
+ [config.run_time, ran_successfully, details]
226
+ )
227
+
228
+ return ran_successfully