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.
@@ -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
- async with aiohttp.ClientSession() as http_client:
48
- response = await http_client.request(
49
- request_type.upper(),
50
- url,
51
- headers=headers,
52
- **kwargs
53
- )
54
- if response.ok:
55
- return await response.json()
56
- else:
57
- return {}
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, runtime: datetime, semaphore: asyncio.Semaphore) -> 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, runtime: datetime, semaphore: asyncio.Semaphore
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
- return Book(
121
- retailer=self.name,
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
- return Book(
139
- retailer=self.name,
140
- title=target.title,
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
- return Book(
149
- retailer=self.name,
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
- from datetime import datetime
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, runtime: datetime, semaphore: asyncio.Semaphore
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
+
@@ -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(copy.deepcopy(book), config.run_time, semaphore)
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
- owned_versions = owned_book_title_map[book.full_title_str]
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}")