tbr-deal-finder 0.1.8__tar.gz → 0.2.1__tar.gz

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 (30) hide show
  1. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/CHANGELOG.md +31 -0
  2. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/PKG-INFO +5 -2
  3. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/README.md +4 -1
  4. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/pyproject.toml +1 -1
  5. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/book.py +38 -9
  6. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/cli.py +2 -1
  7. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/config.py +1 -1
  8. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/migrations.py +26 -0
  9. tbr_deal_finder-0.2.1/tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
  10. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/retailer/__init__.py +2 -0
  11. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/retailer/amazon.py +10 -4
  12. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/retailer/audible.py +2 -3
  13. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/retailer/chirp.py +26 -24
  14. tbr_deal_finder-0.2.1/tbr_deal_finder/retailer/kindle.py +153 -0
  15. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/retailer/librofm.py +22 -17
  16. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/retailer/models.py +36 -0
  17. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/retailer_deal.py +24 -7
  18. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/tracked_books.py +87 -6
  19. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/uv.lock +1 -1
  20. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/.github/workflows/publish-to-pypi.yaml +0 -0
  21. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/.gitignore +0 -0
  22. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/.python-version +0 -0
  23. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/DESIGN.md +0 -0
  24. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/LICENSE +0 -0
  25. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/__init__.py +0 -0
  26. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/owned_books.py +0 -0
  27. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
  28. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
  29. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
  30. {tbr_deal_finder-0.1.8 → tbr_deal_finder-0.2.1}/tbr_deal_finder/utils.py +0 -0
@@ -3,6 +3,37 @@
3
3
 
4
4
  ---
5
5
 
6
+ ## 0.2.1 (August 25, 2025)
7
+
8
+ Notes:
9
+ * Added Kindle Library support
10
+ * Wishlist support is looking unlikely
11
+ * Running into auth issues on the only viable endpoint https://www.amazon.com/kindle-reader-api
12
+ * No longer attempting to retrieve details on books not previously found on every run
13
+ * Full check is now performed weekly or when a change has been made to the user config
14
+
15
+ BUG FIXES:
16
+ * Failed Libro login no longer causing crash
17
+
18
+ ---
19
+
20
+ ## 0.2.0 (August 15, 2025)
21
+
22
+ Notes:
23
+ * Added foundational Kindle support
24
+ * Library support is undecided right now
25
+ * Unable to find the endpoint
26
+ * Wishlist support is undecided right now
27
+ * Unable to find the endpoint
28
+ * Improvements to title matching for Audible & Chirp
29
+ * Improved request performance for Chirp & Libro
30
+
31
+ BUG FIXES:
32
+ * Fixed breaking import on Windows systems
33
+ * Fixed displayed discount percent
34
+
35
+ ---
36
+
6
37
  ## 0.1.8 (August 13, 2025)
7
38
 
8
39
  Notes:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tbr-deal-finder
3
- Version: 0.1.8
3
+ Version: 0.2.1
4
4
  Summary: Track price drops and find deals on books in your TBR list across audiobook and ebook formats.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -23,7 +23,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
23
23
  ## Features
24
24
  - Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
25
25
  - Supports multiple of the library exports above
26
- - Tracks deals on the wishlist of all your configured retailers like audible
26
+ - Tracks deals on the wishlist of all your configured retailers like audible (excluding kindle)
27
27
  - Supports multiple locales and currencies
28
28
  - Find the latest and active deals from supported sellers
29
29
  - Simple CLI interface for setup and usage
@@ -37,6 +37,9 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
37
37
  * Chirp
38
38
  * Libro.fm
39
39
 
40
+ ### EBooks
41
+ * Kindle
42
+
40
43
  ### Locales
41
44
  * US
42
45
  * CA
@@ -5,7 +5,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
5
5
  ## Features
6
6
  - Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
7
7
  - Supports multiple of the library exports above
8
- - Tracks deals on the wishlist of all your configured retailers like audible
8
+ - Tracks deals on the wishlist of all your configured retailers like audible (excluding kindle)
9
9
  - Supports multiple locales and currencies
10
10
  - Find the latest and active deals from supported sellers
11
11
  - Simple CLI interface for setup and usage
@@ -19,6 +19,9 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
19
19
  * Chirp
20
20
  * Libro.fm
21
21
 
22
+ ### EBooks
23
+ * Kindle
24
+
22
25
  ### Locales
23
26
  * US
24
27
  * CA
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tbr-deal-finder"
3
- version = "0.1.8"
3
+ version = "0.2.1"
4
4
  description = "Track price drops and find deals on books in your TBR list across audiobook and ebook formats."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -8,7 +8,7 @@ from Levenshtein import ratio
8
8
  from unidecode import unidecode
9
9
 
10
10
  from tbr_deal_finder.config import Config
11
- from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name
11
+ from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name, echo_info
12
12
 
13
13
  _AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
14
14
 
@@ -36,7 +36,7 @@ class Book:
36
36
  exists: bool = True,
37
37
  ):
38
38
  self.retailer = retailer
39
- self.title = title.split(":")[0].split("(")[0].strip()
39
+ self.title = get_normalized_title(title)
40
40
  self.authors = authors
41
41
  self.timepoint = timepoint
42
42
 
@@ -55,7 +55,10 @@ class Book:
55
55
  self.format = format
56
56
 
57
57
  def discount(self) -> int:
58
- return int((self.list_price/self.current_price - 1) * 100)
58
+ if not self.current_price:
59
+ return 100
60
+
61
+ return int((1 - self.current_price/self.list_price) * 100)
59
62
 
60
63
  @staticmethod
61
64
  def price_to_string(price: float) -> str:
@@ -127,6 +130,15 @@ class Book:
127
130
  "book_id": self.title_id,
128
131
  }
129
132
 
133
+ def unknown_book_dict(self):
134
+ return {
135
+ "retailer": self.retailer,
136
+ "title": self.title,
137
+ "authors": self.authors,
138
+ "format": self.format.value,
139
+ "book_id": self.deal_id,
140
+ }
141
+
130
142
 
131
143
  def get_deals_found_at(timepoint: datetime) -> list[Book]:
132
144
  db_conn = get_duckdb_conn()
@@ -148,13 +160,26 @@ def get_active_deals() -> list[Book]:
148
160
 
149
161
 
150
162
  def print_books(books: list[Book]):
151
- prior_title_id = books[0].title_id
152
- for book in books:
153
- if prior_title_id != book.title_id:
154
- prior_title_id = book.title_id
155
- click.echo()
163
+ audiobooks = [book for book in books if book.format == BookFormat.AUDIOBOOK]
164
+ audiobooks = sorted(audiobooks, key=lambda book: book.deal_id)
165
+
166
+ ebooks = [book for book in books if book.format == BookFormat.EBOOK]
167
+ ebooks = sorted(ebooks, key=lambda book: book.deal_id)
168
+
169
+ for books_in_format in [audiobooks, ebooks]:
170
+ if not books_in_format:
171
+ continue
172
+
173
+ init_book = books_in_format[0]
174
+ echo_info(f"\n\n{init_book.format.value} Deals:")
156
175
 
157
- click.echo(str(book))
176
+ prior_title_id = init_book.title_id
177
+ for book in books_in_format:
178
+ if prior_title_id != book.title_id:
179
+ prior_title_id = book.title_id
180
+ click.echo()
181
+
182
+ click.echo(str(book))
158
183
 
159
184
 
160
185
  def get_full_title_str(title: str, authors: Union[list, str]) -> str:
@@ -165,6 +190,10 @@ def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat)
165
190
  return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
166
191
 
167
192
 
193
+ def get_normalized_title(title: str) -> str:
194
+ return title.split(":")[0].split("(")[0].strip()
195
+
196
+
168
197
  def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
169
198
  if isinstance(authors, str):
170
199
  authors = [i for i in authors.split(",")]
@@ -13,7 +13,7 @@ from tbr_deal_finder.migrations import make_migrations
13
13
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
14
14
  from tbr_deal_finder.retailer import RETAILER_MAP
15
15
  from tbr_deal_finder.retailer_deal import get_latest_deals
16
- from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books
16
+ from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books, clear_unknown_books
17
17
  from tbr_deal_finder.utils import (
18
18
  echo_err,
19
19
  echo_info,
@@ -183,6 +183,7 @@ def setup():
183
183
  # Retailers may have changed causing some books to need reprocessing
184
184
  config = Config.load()
185
185
  reprocess_incomplete_tbr_books(config)
186
+ clear_unknown_books()
186
187
 
187
188
 
188
189
  @cli.command()
@@ -26,7 +26,7 @@ class Config:
26
26
  library_export_paths: list[str]
27
27
  tracked_retailers: list[str]
28
28
  max_price: float = 8.0
29
- min_discount: int = 35
29
+ min_discount: int = 30
30
30
  run_time: datetime = datetime.now()
31
31
 
32
32
  locale: str = "us" # This will be set as a class attribute below
@@ -62,6 +62,32 @@ _MIGRATIONS = [
62
62
  );
63
63
  """
64
64
  ),
65
+ TableMigration(
66
+ version=1,
67
+ table_name="unknown_book",
68
+ sql="""
69
+ CREATE TABLE unknown_book
70
+ (
71
+ retailer VARCHAR,
72
+ title VARCHAR,
73
+ authors VARCHAR,
74
+ format VARCHAR,
75
+ book_id VARCHAR
76
+ );
77
+ """
78
+ ),
79
+ TableMigration(
80
+ version=1,
81
+ table_name="unknown_book_run_history",
82
+ sql="""
83
+ CREATE TABLE unknown_book_run_history
84
+ (
85
+ timepoint TIMESTAMP_NS,
86
+ ran_successfully BOOLEAN,
87
+ details VARCHAR
88
+ );
89
+ """
90
+ ),
65
91
  ]
66
92
 
67
93
 
@@ -0,0 +1,5 @@
1
+ SELECT timepoint
2
+ FROM unknown_book_run_history
3
+ WHERE ran_successfully = TRUE
4
+ ORDER BY timepoint DESC
5
+ LIMIT 1
@@ -1,9 +1,11 @@
1
1
  from tbr_deal_finder.retailer.audible import Audible
2
2
  from tbr_deal_finder.retailer.chirp import Chirp
3
+ from tbr_deal_finder.retailer.kindle import Kindle
3
4
  from tbr_deal_finder.retailer.librofm import LibroFM
4
5
 
5
6
  RETAILER_MAP = {
6
7
  "Audible": Audible,
7
8
  "Chirp": Chirp,
8
9
  "Libro.FM": LibroFM,
10
+ "Kindle": Kindle,
9
11
  }
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  import os.path
2
3
 
3
4
  import audible
@@ -5,11 +6,16 @@ import click
5
6
  from audible.login import build_init_cookies
6
7
  from textwrap import dedent
7
8
 
9
+ if sys.platform != 'win32':
10
+ # Breaks Windows support but required for Mac
11
+ # Untested on Linux
12
+ import readline # type: ignore
13
+
8
14
  from tbr_deal_finder import TBR_DEALS_PATH
9
15
  from tbr_deal_finder.config import Config
10
16
  from tbr_deal_finder.retailer.models import Retailer
11
17
 
12
- _AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
18
+ AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
13
19
 
14
20
 
15
21
  def login_url_callback(url: str) -> str:
@@ -65,15 +71,15 @@ class Amazon(Retailer):
65
71
  _client: audible.AsyncClient = None
66
72
 
67
73
  async def set_auth(self):
68
- if not os.path.exists(_AUTH_PATH):
74
+ if not os.path.exists(AUTH_PATH):
69
75
  auth = audible.Authenticator.from_login_external(
70
76
  locale=Config.locale,
71
77
  login_url_callback=login_url_callback
72
78
  )
73
79
 
74
80
  # Save credentials to file
75
- auth.to_file(_AUTH_PATH)
81
+ auth.to_file(AUTH_PATH)
76
82
 
77
- self._auth = audible.Authenticator.from_file(_AUTH_PATH)
83
+ self._auth = audible.Authenticator.from_file(AUTH_PATH)
78
84
  self._client = audible.AsyncClient(auth=self._auth)
79
85
 
@@ -1,10 +1,9 @@
1
1
  import asyncio
2
2
  import math
3
- import readline # type: ignore
4
3
 
5
4
  from tbr_deal_finder.config import Config
6
5
  from tbr_deal_finder.retailer.amazon import Amazon
7
- from tbr_deal_finder.book import Book, BookFormat
6
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_title
8
7
 
9
8
 
10
9
  class Audible(Amazon):
@@ -37,7 +36,7 @@ class Audible(Amazon):
37
36
  )
38
37
 
39
38
  for product in match.get("products", []):
40
- if product["title"] != title:
39
+ if get_normalized_title(product["title"]) != title:
41
40
  continue
42
41
  try:
43
42
  target.list_price = product["price"]["list_price"]["base"]
@@ -9,17 +9,19 @@ import click
9
9
 
10
10
  from tbr_deal_finder import TBR_DEALS_PATH
11
11
  from tbr_deal_finder.config import Config
12
- from tbr_deal_finder.retailer.models import Retailer
12
+ from tbr_deal_finder.retailer.models import AioHttpSession, Retailer
13
13
  from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
14
14
  from tbr_deal_finder.utils import currency_to_float, echo_err
15
15
 
16
16
 
17
- class Chirp(Retailer):
17
+ class Chirp(AioHttpSession, Retailer):
18
18
  # Static because url for other locales just redirects to .com
19
19
  _url: str = "https://api.chirpbooks.com/api/graphql"
20
20
  USER_AGENT = "ChirpBooks/5.13.9 (Android)"
21
21
 
22
22
  def __init__(self):
23
+ super().__init__()
24
+
23
25
  self.auth_token = None
24
26
 
25
27
  @property
@@ -38,17 +40,17 @@ class Chirp(Retailer):
38
40
  if self.auth_token:
39
41
  headers["authorization"] = f"Bearer {self.auth_token}"
40
42
 
41
- async with aiohttp.ClientSession() as http_client:
42
- response = await http_client.request(
43
- request_type.upper(),
44
- self._url,
45
- headers=headers,
46
- **kwargs
47
- )
48
- if response.ok:
49
- return await response.json()
50
- else:
51
- return {}
43
+ session = await self._get_session()
44
+ response = await session.request(
45
+ request_type.upper(),
46
+ self._url,
47
+ headers=headers,
48
+ **kwargs
49
+ )
50
+ if response.ok:
51
+ return await response.json()
52
+ else:
53
+ return {}
52
54
 
53
55
  async def set_auth(self):
54
56
  auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
@@ -88,17 +90,17 @@ class Chirp(Retailer):
88
90
  ) -> Book:
89
91
  title = target.title
90
92
  async with semaphore:
91
- async with aiohttp.ClientSession() as http_client:
92
- response = await http_client.request(
93
- "POST",
94
- self._url,
95
- json={
96
- "query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment audiobookWithShoppingCartAndUserAudiobookFields on Audiobook{...audiobookFields currentUserShoppingCartItem{id}currentUserWishlistItem{id}currentUserUserAudiobook{id}currentUserHasAuthorFollow{id}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query AudiobookSearch($query:String!,$promotionFilter:String,$filter:String,$page:Int,$pageSize:Int){audiobooks(query:$query,promotionFilter:$promotionFilter,filter:$filter,page:$page,pageSize:$pageSize){totalCount objects(page:$page,pageSize:$pageSize){... on Audiobook{...audiobookWithShoppingCartAndUserAudiobookFields futureSaleDate currentProduct{...productFields}}}}}",
97
- "variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
98
- "operationName": "AudiobookSearch"
99
- }
100
- )
101
- response_body = await response.json()
93
+ session = await self._get_session()
94
+ response = await session.request(
95
+ "POST",
96
+ self._url,
97
+ json={
98
+ "query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment audiobookWithShoppingCartAndUserAudiobookFields on Audiobook{...audiobookFields currentUserShoppingCartItem{id}currentUserWishlistItem{id}currentUserUserAudiobook{id}currentUserHasAuthorFollow{id}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query AudiobookSearch($query:String!,$promotionFilter:String,$filter:String,$page:Int,$pageSize:Int){audiobooks(query:$query,promotionFilter:$promotionFilter,filter:$filter,page:$page,pageSize:$pageSize){totalCount objects(page:$page,pageSize:$pageSize){... on Audiobook{...audiobookWithShoppingCartAndUserAudiobookFields futureSaleDate currentProduct{...productFields}}}}}",
99
+ "variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
100
+ "operationName": "AudiobookSearch"
101
+ }
102
+ )
103
+ response_body = await response.json()
102
104
 
103
105
  audiobooks = response_body["data"]["audiobooks"]["objects"]
104
106
  if not audiobooks:
@@ -0,0 +1,153 @@
1
+ import asyncio
2
+ import json
3
+ import readline # type: ignore
4
+
5
+ from tbr_deal_finder.config import Config
6
+ from tbr_deal_finder.retailer.amazon import Amazon, AUTH_PATH
7
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_title, get_normalized_authors, is_matching_authors
8
+
9
+
10
+ class Kindle(Amazon):
11
+
12
+ def __init__(self):
13
+ self._headers = {}
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ return "Kindle"
18
+
19
+ @property
20
+ def format(self) -> BookFormat:
21
+ return BookFormat.EBOOK
22
+
23
+ def _get_base_url(self) -> str:
24
+ return f"https://www.amazon.{self._auth.locale.domain}"
25
+
26
+ def _get_read_base_url(self) -> str:
27
+ return f"https://read.amazon.{self._auth.locale.domain}"
28
+
29
+ async def set_auth(self):
30
+ await super().set_auth()
31
+
32
+ with open(AUTH_PATH, "r") as f:
33
+ auth_info = json.load(f)
34
+
35
+ cookies = auth_info["website_cookies"]
36
+ cookies["x-access-token"] = auth_info["access_token"]
37
+
38
+ self._headers = {
39
+ "User-Agent": "Mozilla/5.0 (Linux; Android 10; Kindle) AppleWebKit/537.3",
40
+ "Accept": "application/json, */*",
41
+ "Cookie": "; ".join([f"{k}={v}" for k, v in cookies.items()])
42
+ }
43
+
44
+
45
+ async def get_book_asin(
46
+ self,
47
+ target: Book,
48
+ semaphore: asyncio.Semaphore
49
+ ) -> Book:
50
+ title = target.title
51
+ async with semaphore:
52
+ match = await self._client.get(
53
+ f"{self._get_base_url()}/kindle-dbs/kws?userCode=AndroidKin&deviceType=A3VNNDO1I14V03&node=2671536011&excludedNodes=&page=1&size=20&autoSpellCheck=1&rank=r",
54
+ query=title,
55
+ )
56
+
57
+ for product in match.get("items", []):
58
+ normalized_authors = get_normalized_authors(product["authors"])
59
+ if (
60
+ get_normalized_title(product["title"]) != title
61
+ or not is_matching_authors(target.normalized_authors, normalized_authors)
62
+ ):
63
+ continue
64
+ try:
65
+ target.ebook_asin = product["asin"]
66
+ break
67
+ except KeyError:
68
+ continue
69
+
70
+ return target
71
+
72
+ async def get_book(
73
+ self,
74
+ target: Book,
75
+ semaphore: asyncio.Semaphore
76
+ ) -> Book:
77
+ target.exists = False
78
+
79
+ if not target.ebook_asin:
80
+ return target
81
+
82
+ asin = target.ebook_asin
83
+ async with semaphore:
84
+ match = await self._client.get(
85
+ f"{self._get_base_url()}/api/bifrost/offers/batch/v1/{asin}?ref_=KindleDeepLinkOffers",
86
+ headers={"x-client-id": "kindle-android-deeplink"},
87
+ )
88
+ products = match.get("resources", [])
89
+ if not products:
90
+ return target
91
+
92
+ actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
93
+ if not actions:
94
+ return target
95
+
96
+ for action in actions:
97
+ if "printListPrice" in action["offer"]:
98
+ target.list_price = action["offer"]["printListPrice"]["value"]
99
+ target.current_price = action["offer"]["digitalPrice"]["value"]
100
+ target.exists = True
101
+ break
102
+
103
+ return target
104
+
105
+ async def get_wishlist(self, config: Config) -> list[Book]:
106
+ """Not currently supported
107
+
108
+ Getting this info is proving to be a nightmare
109
+
110
+ :param config:
111
+ :return:
112
+ """
113
+ return []
114
+
115
+ async def get_library(self, config: Config) -> list[Book]:
116
+ books = []
117
+ pagination_token = 0
118
+ url = f"{self._get_read_base_url()}/kindle-library/search"
119
+
120
+ while True:
121
+ optional_params = {}
122
+ if pagination_token:
123
+ optional_params["paginationToken"] = pagination_token
124
+
125
+ response = await self._client.get(
126
+ url,
127
+ headers=self._headers,
128
+ query="",
129
+ libraryType="BOOKS",
130
+ sortType="recency",
131
+ resourceType="EBOOK",
132
+ querySize=50,
133
+ **optional_params
134
+ )
135
+
136
+ for book in response["itemsList"]:
137
+ books.append(
138
+ Book(
139
+ retailer=self.name,
140
+ title = book["title"],
141
+ authors = book["authors"][0],
142
+ format=self.format,
143
+ timepoint=config.run_time,
144
+ ebook_asin=book["asin"],
145
+ )
146
+ )
147
+
148
+ if "paginationToken" in response:
149
+ pagination_token = int(response["paginationToken"])
150
+ else:
151
+ break
152
+
153
+ return books
@@ -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
14
- from tbr_deal_finder.utils import currency_to_float
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
13
+ from tbr_deal_finder.utils import currency_to_float, echo_err
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,6 +77,10 @@ class LibroFM(Retailer):
76
77
  "password": click.prompt("Libro FM Password", hide_input=True),
77
78
  }
78
79
  )
80
+ if "access_token" not in response:
81
+ echo_err("Login failed. Try again.")
82
+ await self.set_auth()
83
+
79
84
  self.auth_token = response["access_token"]
80
85
  with open(auth_path, "w") as f:
81
86
  json.dump(response, f)
@@ -100,7 +105,7 @@ class LibroFM(Retailer):
100
105
  normalized_authors = get_normalized_authors(b["authors"])
101
106
 
102
107
  if (
103
- title == b["title"]
108
+ title == get_normalized_title(b["title"])
104
109
  and is_matching_authors(book.normalized_authors, normalized_authors)
105
110
  ):
106
111
  book.audiobook_isbn = b["isbn"]
@@ -1,6 +1,8 @@
1
1
  import abc
2
2
  import asyncio
3
3
 
4
+ import aiohttp
5
+
4
6
  from tbr_deal_finder.book import Book, BookFormat
5
7
  from tbr_deal_finder.config import Config
6
8
 
@@ -52,3 +54,37 @@ class Retailer(abc.ABC):
52
54
  async def get_library(self, config: Config) -> list[Book]:
53
55
  raise NotImplementedError
54
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,7 @@ 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
14
  from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
@@ -55,7 +55,12 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
55
55
  db_conn.unregister("_df")
56
56
 
57
57
 
58
- async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
58
+ async def _get_books(
59
+ config,
60
+ retailer: Retailer,
61
+ books: list[Book],
62
+ ignored_deal_ids: set[str],
63
+ ) -> tuple[list[Book], list[Book]]:
59
64
  """Get Books with limited concurrency.
60
65
 
61
66
  - Creates semaphore to limit concurrent requests.
@@ -72,7 +77,7 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
72
77
  """
73
78
  semaphore = asyncio.Semaphore(10)
74
79
  response = []
75
- unresolved_books = []
80
+ unknown_books = []
76
81
  books = [copy.deepcopy(book) for book in books]
77
82
  for book in books:
78
83
  book.retailer = retailer.name
@@ -81,19 +86,20 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
81
86
  tasks = [
82
87
  retailer.get_book(book, semaphore)
83
88
  for book in books
89
+ if book.deal_id not in ignored_deal_ids
84
90
  ]
85
91
  results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
86
92
  for book in results:
87
93
  if book.exists:
88
94
  response.append(book)
89
95
  elif not book.exists:
90
- unresolved_books.append(book)
96
+ unknown_books.append(book)
91
97
 
92
98
  click.echo()
93
- for book in unresolved_books:
99
+ for book in unknown_books:
94
100
  echo_info(f"{book.title} by {book.authors} not found")
95
101
 
96
- return response
102
+ return response, unknown_books
97
103
 
98
104
 
99
105
  def _apply_proper_list_prices(books: list[Book]):
@@ -169,7 +175,10 @@ async def get_latest_deals(config: Config):
169
175
  """
170
176
 
171
177
  books: list[Book] = []
178
+ unknown_books: list[Book] = []
172
179
  tbr_books = await get_tbr_books(config)
180
+ ignore_books: list[Book] = get_unknown_books(config)
181
+ ignored_deal_ids: set[str] = {book.deal_id for book in ignore_books}
173
182
 
174
183
  for retailer_str in config.tracked_retailers:
175
184
  retailer = RETAILER_MAP[retailer_str]()
@@ -182,7 +191,14 @@ async def get_latest_deals(config: Config):
182
191
 
183
192
  echo_info(f"Getting deals from {retailer.name}")
184
193
  click.echo("\n---------------")
185
- books.extend(await _get_books(config, retailer, relevant_tbr_books))
194
+ retailer_books, u_books = await _get_books(
195
+ config,
196
+ retailer,
197
+ relevant_tbr_books,
198
+ ignored_deal_ids
199
+ )
200
+ books.extend(retailer_books)
201
+ unknown_books.extend(u_books)
186
202
  click.echo("---------------\n")
187
203
 
188
204
  _apply_proper_list_prices(books)
@@ -194,3 +210,4 @@ async def get_latest_deals(config: Config):
194
210
  ]
195
211
 
196
212
  update_retailer_deal_table(config, books)
213
+ set_unknown_books(config, unknown_books)
@@ -1,7 +1,9 @@
1
1
  import asyncio
2
2
  import copy
3
3
  import csv
4
+ import functools
4
5
  from collections import defaultdict
6
+ from datetime import datetime, timedelta
5
7
  from typing import Callable, Awaitable, Optional
6
8
 
7
9
  import pandas as pd
@@ -9,10 +11,10 @@ from tqdm.asyncio import tqdm_asyncio
9
11
 
10
12
  from tbr_deal_finder.book import Book, BookFormat, get_title_id
11
13
  from tbr_deal_finder.owned_books import get_owned_books
12
- from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM
14
+ from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM, Kindle
13
15
  from tbr_deal_finder.config import Config
14
16
  from tbr_deal_finder.retailer.models import Retailer
15
- from tbr_deal_finder.utils import execute_query, get_duckdb_conn
17
+ from tbr_deal_finder.utils import execute_query, get_duckdb_conn, get_query_by_name
16
18
 
17
19
 
18
20
  def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
@@ -194,10 +196,6 @@ async def _maybe_set_audiobook_list_price(config: Config, new_tbr_books: list[Bo
194
196
 
195
197
  async def _maybe_set_audiobook_isbn(config: Config, new_tbr_books: list[Book]):
196
198
  """To get the price from Libro.fm for a book, you need its ISBN
197
-
198
- As opposed to trying to get that every time latest-deals is run
199
- we're just updating the export csv once to include the ISBN.
200
-
201
199
  """
202
200
  if "Libro.FM" not in config.tracked_retailers:
203
201
  return
@@ -218,6 +216,88 @@ async def _maybe_set_audiobook_isbn(config: Config, new_tbr_books: list[Book]):
218
216
  )
219
217
 
220
218
 
219
+ @functools.cache
220
+ def unknown_books_requires_sync() -> bool:
221
+ db_conn = get_duckdb_conn()
222
+ results = execute_query(
223
+ db_conn,
224
+ get_query_by_name("latest_unknown_book_sync.sql")
225
+ )
226
+ if not results:
227
+ return True
228
+
229
+ sync_last_ran = results[0]["timepoint"]
230
+ return datetime.now() - timedelta(days=7) > sync_last_ran
231
+
232
+
233
+ def clear_unknown_books():
234
+ db_conn = get_duckdb_conn()
235
+ db_conn.execute(
236
+ "DELETE FROM unknown_book"
237
+ )
238
+ db_conn.execute(
239
+ "DELETE FROM unknown_book_run_history"
240
+ )
241
+
242
+
243
+ def set_unknown_books(config: Config, unknown_books: list[Book]):
244
+ if not unknown_books_requires_sync():
245
+ return
246
+
247
+ db_conn = get_duckdb_conn()
248
+ db_conn.execute(
249
+ "INSERT INTO unknown_book_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
250
+ [config.run_time, True, ""]
251
+ )
252
+
253
+ db_conn.execute(
254
+ "DELETE FROM unknown_book"
255
+ )
256
+ if not unknown_books:
257
+ return
258
+
259
+ df = pd.DataFrame([book.unknown_book_dict() for book in unknown_books])
260
+ db_conn = get_duckdb_conn()
261
+ db_conn.register("_df", df)
262
+ db_conn.execute("INSERT INTO unknown_book SELECT * FROM _df;")
263
+ db_conn.unregister("_df")
264
+
265
+
266
+ def get_unknown_books(config: Config) -> list[Book]:
267
+ if unknown_books_requires_sync():
268
+ return []
269
+
270
+ db_conn = get_duckdb_conn()
271
+ unknown_book_data = execute_query(
272
+ db_conn,
273
+ "SELECT * EXCLUDE(book_id) FROM unknown_book"
274
+ )
275
+
276
+ return [Book(timepoint=config.run_time, **b) for b in unknown_book_data]
277
+
278
+
279
+ async def _maybe_set_ebook_asin(config: Config, new_tbr_books: list[Book]):
280
+ """To get the price from kindle for a book, you need its asin
281
+ """
282
+ if "Kindle" not in config.tracked_retailers:
283
+ return
284
+
285
+ kindle = Kindle()
286
+ await kindle.set_auth()
287
+
288
+ relevant_tbr_books = [
289
+ book
290
+ for book in new_tbr_books
291
+ if book.format in [BookFormat.EBOOK, BookFormat.NA]
292
+ ]
293
+
294
+ await _set_tbr_book_attr(
295
+ relevant_tbr_books,
296
+ "ebook_asin",
297
+ kindle.get_book_asin,
298
+ )
299
+
300
+
221
301
  def get_book_authors(book: dict) -> str:
222
302
  if authors := book.get('Authors'):
223
303
  return authors
@@ -301,6 +381,7 @@ async def sync_tbr_books(config: Config):
301
381
 
302
382
  await _maybe_set_audiobook_list_price(config, new_tbr_books)
303
383
  await _maybe_set_audiobook_isbn(config, new_tbr_books)
384
+ await _maybe_set_ebook_asin(config, new_tbr_books)
304
385
 
305
386
  df = pd.DataFrame([book.tbr_dict() for book in new_tbr_books])
306
387
  db_conn.register("_df", df)
@@ -612,7 +612,7 @@ wheels = [
612
612
 
613
613
  [[package]]
614
614
  name = "tbr-deal-finder"
615
- version = "0.1.8"
615
+ version = "0.2.1"
616
616
  source = { editable = "." }
617
617
  dependencies = [
618
618
  { name = "aiohttp" },
File without changes