tbr-deal-finder 0.1.5__tar.gz → 0.1.6__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 (29) hide show
  1. tbr_deal_finder-0.1.6/CHANGELOG.md +21 -0
  2. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/PKG-INFO +3 -2
  3. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/README.md +2 -1
  4. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/pyproject.toml +1 -1
  5. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/book.py +18 -5
  6. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/cli.py +10 -5
  7. tbr_deal_finder-0.1.6/tbr_deal_finder/library_exports.py +205 -0
  8. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/audible.py +53 -26
  9. tbr_deal_finder-0.1.6/tbr_deal_finder/retailer/chirp.py +182 -0
  10. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/librofm.py +58 -7
  11. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/models.py +18 -2
  12. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer_deal.py +14 -5
  13. tbr_deal_finder-0.1.6/tbr_deal_finder/tracked_books.py +120 -0
  14. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/uv.lock +1 -1
  15. tbr_deal_finder-0.1.5/CHANGELOG.md +0 -12
  16. tbr_deal_finder-0.1.5/tbr_deal_finder/library_exports.py +0 -155
  17. tbr_deal_finder-0.1.5/tbr_deal_finder/retailer/chirp.py +0 -80
  18. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/.github/workflows/publish-to-pypi.yaml +0 -0
  19. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/.gitignore +0 -0
  20. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/.python-version +0 -0
  21. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/LICENSE +0 -0
  22. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/__init__.py +0 -0
  23. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/config.py +0 -0
  24. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/migrations.py +0 -0
  25. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
  26. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
  27. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
  28. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/__init__.py +0 -0
  29. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/utils.py +0 -0
@@ -0,0 +1,21 @@
1
+
2
+ # Change Log
3
+
4
+ ## 0.1.6 (July 30, 2025)
5
+
6
+ Notes:
7
+ * tbr-deal-finder now also tracks deals on the books in your wishlist. Works for all retailers.
8
+
9
+ BUG FIXES:
10
+ * Fixed issue where no deals would display if libro is the only tracked audiobook retailer.
11
+ * Fixed retailer cli setup forcing a user to select at least two audiobook retailers.
12
+
13
+ ## 0.1.5 (July 30, 2025)
14
+
15
+ Notes:
16
+ * Added formatting to select messages to make the messages purpose clearer.
17
+
18
+ BUG FIXES:
19
+ * Fixed issue getting books from libro and chirp too aggressively
20
+ * User must now track deals for at least one retailer
21
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tbr-deal-finder
3
- Version: 0.1.5
3
+ Version: 0.1.6
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
@@ -21,7 +21,8 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
21
21
 
22
22
  ## Features
23
23
  - Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
24
- - Supports multiple of the library exports above
24
+ - Supports multiple of the library exports above
25
+ - Tracks deals on the wishlist of all your configured retailers like audible
25
26
  - Supports multiple locales and currencies
26
27
  - Finds the latest and active deals from supported sellers
27
28
  - Simple CLI interface for setup and usage
@@ -4,7 +4,8 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
4
4
 
5
5
  ## Features
6
6
  - Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
7
- - Supports multiple of the library exports above
7
+ - Supports multiple of the library exports above
8
+ - Tracks deals on the wishlist of all your configured retailers like audible
8
9
  - Supports multiple locales and currencies
9
10
  - Finds the latest and active deals from supported sellers
10
11
  - Simple CLI interface for setup and usage
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tbr-deal-finder"
3
- version = "0.1.5"
3
+ version = "0.1.6"
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"
@@ -26,7 +26,11 @@ class Book:
26
26
  current_price: float
27
27
  timepoint: datetime
28
28
  format: Union[BookFormat, str]
29
+
30
+ # Metadata really only used for tracked books.
31
+ # See get_tbr_books for more context
29
32
  audiobook_isbn: str = None
33
+ audiobook_list_price: float = 0
30
34
 
31
35
  deleted: bool = False
32
36
 
@@ -35,16 +39,16 @@ class Book:
35
39
  normalized_authors: list[str] = None
36
40
 
37
41
  def __post_init__(self):
42
+ self.current_price = round(self.current_price, 2)
43
+ self.list_price = round(self.list_price, 2)
44
+ self.normalized_authors = get_normalized_authors(self.authors)
45
+
38
46
  if not self.deal_id:
39
- self.deal_id = f"{self.title}__{self.normalized_authors}__{self.retailer}__{self.format}"
47
+ self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
40
48
 
41
49
  if isinstance(self.format, str):
42
50
  self.format = BookFormat(self.format)
43
51
 
44
- self.current_price = round(self.current_price, 2)
45
- self.list_price = round(self.list_price, 2)
46
- self.normalized_authors = get_normalized_authors(self.authors)
47
-
48
52
  def discount(self) -> int:
49
53
  return int((self.list_price/self.current_price - 1) * 100)
50
54
 
@@ -56,6 +60,10 @@ class Book:
56
60
  def title_id(self) -> str:
57
61
  return f"{self.title}__{self.normalized_authors}__{self.format}"
58
62
 
63
+ @property
64
+ def full_title_str(self) -> str:
65
+ return f"{self.title}__{self.normalized_authors}"
66
+
59
67
  def list_price_string(self):
60
68
  return self.price_to_string(self.list_price)
61
69
 
@@ -74,6 +82,7 @@ class Book:
74
82
  response = dataclasses.asdict(self)
75
83
  response["format"] = self.format.value
76
84
  del response["audiobook_isbn"]
85
+ del response["audiobook_list_price"]
77
86
  del response["exists"]
78
87
  del response["normalized_authors"]
79
88
 
@@ -114,3 +123,7 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
114
123
  authors = [i for i in authors.split(",")]
115
124
 
116
125
  return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
126
+
127
+
128
+ def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
129
+ return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
@@ -9,7 +9,7 @@ import click
9
9
  import questionary
10
10
 
11
11
  from tbr_deal_finder.config import Config
12
- from tbr_deal_finder.library_exports import maybe_set_library_export_audiobook_isbn
12
+ from tbr_deal_finder.library_exports import maybe_enrich_library_exports
13
13
  from tbr_deal_finder.migrations import make_migrations
14
14
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
15
15
  from tbr_deal_finder.retailer import RETAILER_MAP
@@ -124,15 +124,20 @@ def _set_locale(config: Config):
124
124
 
125
125
 
126
126
  def _set_tracked_retailers(config: Config):
127
+ if not config.tracked_retailers:
128
+ echo_info(
129
+ "If you haven't heard of it, Chirp doesn't charge a subscription and has some great deals. \n"
130
+ "Note: I don't work for Chirp and this isn't a paid plug."
131
+ )
132
+
127
133
  while True:
128
134
  user_response = questionary.checkbox(
129
- "Select the retailers you want to check deals for.\n"
130
- "Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.\n",
135
+ "Select the retailers you want to check deals for.\n",
131
136
  choices=[
132
137
  questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
133
138
  for retailer in RETAILER_MAP.keys()
134
139
  ]).ask()
135
- if len(user_response) > 1:
140
+ if len(user_response) > 0:
136
141
  break
137
142
  else:
138
143
  echo_err("You must track deals for at least one retailer.")
@@ -185,7 +190,7 @@ def latest_deals():
185
190
  """Find book deals from your Library export."""
186
191
  config = Config.load()
187
192
 
188
- asyncio.run(maybe_set_library_export_audiobook_isbn(config))
193
+ asyncio.run(maybe_enrich_library_exports(config))
189
194
 
190
195
  db_conn = get_duckdb_conn()
191
196
  results = execute_query(
@@ -0,0 +1,205 @@
1
+ import asyncio
2
+ import csv
3
+ import shutil
4
+ import tempfile
5
+ from datetime import datetime
6
+ from typing import Callable, Awaitable, Optional
7
+
8
+ from tqdm.asyncio import tqdm_asyncio
9
+
10
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
11
+ from tbr_deal_finder.config import Config
12
+ from tbr_deal_finder.retailer import LibroFM, Chirp
13
+
14
+
15
+ def get_book_authors(book: dict) -> str:
16
+ if authors := book.get('Authors'):
17
+ return authors
18
+
19
+ authors = book['Author']
20
+ if additional_authors := book.get("Additional Authors"):
21
+ authors = f"{authors}, {additional_authors}"
22
+
23
+ return authors
24
+
25
+
26
+ def get_book_title(book: dict) -> str:
27
+ title = book['Title']
28
+ return title.split("(")[0].strip()
29
+
30
+
31
+ def is_tbr_book(book: dict) -> bool:
32
+ if "Read Status" in book:
33
+ return book["Read Status"] == "to-read"
34
+ elif "Bookshelves" in book:
35
+ return "to-read" in book["Bookshelves"]
36
+ else:
37
+ return True
38
+
39
+
40
+ def requires_audiobook_list_price_default(config: Config) -> bool:
41
+ return bool(
42
+ "Libro.FM" in config.tracked_retailers
43
+ and "Audible" not in config.tracked_retailers
44
+ and "Chirp" not in config.tracked_retailers
45
+ )
46
+
47
+
48
+ async def _maybe_set_column_for_library_exports(
49
+ config: Config,
50
+ attr_name: str,
51
+ get_book_callable: Callable[[Book, datetime, asyncio.Semaphore], Awaitable[Book]],
52
+ column_name: Optional[str] = None,
53
+ ):
54
+ """Adds a new column to all library exports that are missing it.
55
+ Uses get_book_callable to set the column value if a matching record couldn't be found
56
+ on that column in any other library export file.
57
+
58
+ :param config:
59
+ :param attr_name:
60
+ :param get_book_callable:
61
+ :param column_name:
62
+ :return:
63
+ """
64
+ if not column_name:
65
+ column_name = attr_name
66
+
67
+ books_requiring_check_map = dict()
68
+ book_to_col_val_map = dict()
69
+
70
+ # Iterate all library export paths
71
+ for library_export_path in config.library_export_paths:
72
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
73
+ # Use csv.DictReader to get dictionaries with column headers
74
+ for book_dict in csv.DictReader(file):
75
+ if not is_tbr_book(book_dict):
76
+ continue
77
+
78
+ title = get_book_title(book_dict)
79
+ authors = get_book_authors(book_dict)
80
+ key = f'{title}__{get_normalized_authors(authors)}'
81
+
82
+ if column_name in book_dict:
83
+ # Keep state of value for this book/key
84
+ # in the event another export has the same book but the value is not set
85
+ book_to_col_val_map[key] = book_dict[column_name]
86
+ # Value has been found so a check no longer needs to be performed
87
+ books_requiring_check_map.pop(key, None)
88
+ elif key not in book_to_col_val_map:
89
+ # Not found, add the book to those requiring the column val to be set
90
+ books_requiring_check_map[key] = Book(
91
+ retailer="N/A",
92
+ title=title,
93
+ authors=authors,
94
+ list_price=0,
95
+ current_price=0,
96
+ timepoint=config.run_time,
97
+ format=BookFormat.NA
98
+ )
99
+
100
+ if not books_requiring_check_map:
101
+ # Everything was resolved, nothing else to do
102
+ return
103
+
104
+ semaphore = asyncio.Semaphore(5)
105
+ human_readable_name = attr_name.replace("_", " ").title()
106
+
107
+ # Get books with the appropriate transform applied
108
+ # Responsibility is on the callable here
109
+ enriched_books = await tqdm_asyncio.gather(
110
+ *[
111
+ get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
112
+ ],
113
+ desc=f"Getting required {human_readable_name} info"
114
+ )
115
+ updated_book_map = {
116
+ b.full_title_str: b
117
+ for b in enriched_books
118
+ }
119
+
120
+
121
+ # Go back and now add the new column where it hasn't been set
122
+ for library_export_path in config.library_export_paths:
123
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
124
+ reader = csv.DictReader(file)
125
+ field_names = list(reader.fieldnames) + [column_name]
126
+ file_content = [book_dict for book_dict in reader]
127
+ if not file_content or column_name in file_content[0]:
128
+ continue
129
+
130
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
131
+ temp_filename = temp_file.name
132
+ writer = csv.DictWriter(temp_file, fieldnames=field_names)
133
+ writer.writeheader()
134
+
135
+ for book_dict in file_content:
136
+ if is_tbr_book(book_dict):
137
+ title = get_book_title(book_dict)
138
+ authors = get_book_authors(book_dict)
139
+ key = f'{title}__{get_normalized_authors(authors)}'
140
+
141
+ if key in book_to_col_val_map:
142
+ col_val = book_to_col_val_map[key]
143
+ elif key in updated_book_map:
144
+ book = updated_book_map[key]
145
+ col_val = getattr(book, attr_name)
146
+ else:
147
+ col_val = ""
148
+
149
+ book_dict[column_name] = col_val
150
+ else:
151
+ book_dict[column_name] = ""
152
+
153
+ writer.writerow(book_dict)
154
+
155
+ shutil.move(temp_filename, library_export_path)
156
+
157
+
158
+ async def _maybe_set_library_export_audiobook_isbn(config: Config):
159
+ """To get the price from Libro.fm for a book, you need its ISBN
160
+
161
+ As opposed to trying to get that every time latest-deals is run
162
+ we're just updating the export csv once to include the ISBN.
163
+
164
+ Unfortunately, we do have to get it at run time for wishlists.
165
+ """
166
+ if "Libro.FM" not in config.tracked_retailers:
167
+ return
168
+
169
+ libro_fm = LibroFM()
170
+ await libro_fm.set_auth()
171
+
172
+ await _maybe_set_column_for_library_exports(
173
+ config,
174
+ "audiobook_isbn",
175
+ libro_fm.get_book_isbn,
176
+ )
177
+
178
+
179
+ async def _maybe_set_library_export_audiobook_list_price(config: Config):
180
+ """Set a default list price for audiobooks
181
+
182
+ Only set if not currently set and the only audiobook retailer is Libro.FM
183
+ Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
184
+ Chirp doesn't require a login to get this price info making it ideal in this instance.
185
+
186
+ :param config:
187
+ :return:
188
+ """
189
+ if not requires_audiobook_list_price_default(config):
190
+ return
191
+
192
+ chirp = Chirp()
193
+ await chirp.set_auth()
194
+
195
+ await _maybe_set_column_for_library_exports(
196
+ config,
197
+ "list_price",
198
+ chirp.get_book,
199
+ "audiobook_list_price"
200
+ )
201
+
202
+
203
+ async def maybe_enrich_library_exports(config: Config):
204
+ await _maybe_set_library_export_audiobook_isbn(config)
205
+ await _maybe_set_library_export_audiobook_list_price(config)
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import math
2
3
  import os.path
3
4
  from datetime import datetime
4
5
  from textwrap import dedent
@@ -22,11 +23,9 @@ def login_url_callback(url: str) -> str:
22
23
 
23
24
  try:
24
25
  from playwright.sync_api import sync_playwright # type: ignore
25
- use_playwright = True
26
26
  except ImportError:
27
- use_playwright = False
28
-
29
- if use_playwright:
27
+ pass
28
+ else:
30
29
  with sync_playwright() as p:
31
30
  iphone = p.devices["iPhone 12 Pro"]
32
31
  browser = p.webkit.launch(headless=False)
@@ -75,6 +74,23 @@ class Audible(Retailer):
75
74
  def name(self) -> str:
76
75
  return "Audible"
77
76
 
77
+ @property
78
+ def format(self) -> BookFormat:
79
+ return BookFormat.AUDIOBOOK
80
+
81
+ async def set_auth(self):
82
+ if not os.path.exists(_AUTH_PATH):
83
+ auth = audible.Authenticator.from_login_external(
84
+ locale=Config.locale,
85
+ login_url_callback=login_url_callback
86
+ )
87
+
88
+ # Save credentials to file
89
+ auth.to_file(_AUTH_PATH)
90
+
91
+ self._auth = audible.Authenticator.from_file(_AUTH_PATH)
92
+ self._client = audible.AsyncClient(auth=self._auth)
93
+
78
94
  async def get_book(
79
95
  self,
80
96
  target: Book,
@@ -95,19 +111,7 @@ class Audible(Retailer):
95
111
  ]
96
112
  )
97
113
 
98
- if not match["products"]:
99
- return Book(
100
- retailer=self.name,
101
- title=title,
102
- authors=authors,
103
- list_price=0,
104
- current_price=0,
105
- timepoint=runtime,
106
- format=BookFormat.AUDIOBOOK,
107
- exists=False,
108
- )
109
-
110
- for product in match["products"]:
114
+ for product in match.get("products", []):
111
115
  if product["title"] != title:
112
116
  continue
113
117
 
@@ -132,15 +136,38 @@ class Audible(Retailer):
132
136
  exists=False,
133
137
  )
134
138
 
135
- async def set_auth(self):
136
- if not os.path.exists(_AUTH_PATH):
137
- auth = audible.Authenticator.from_login_external(
138
- locale=Config.locale,
139
- login_url_callback=login_url_callback
139
+ async def get_wishlist(self, config: Config) -> list[Book]:
140
+ wishlist_books = []
141
+
142
+ page = 0
143
+ total_pages = 1
144
+ page_size = 50
145
+ while page < total_pages:
146
+ response = await self._client.get(
147
+ "1.0/wishlist",
148
+ num_results=page_size,
149
+ page=page,
150
+ response_groups=[
151
+ "contributors, product_attrs, product_desc, product_extended_attrs"
152
+ ]
140
153
  )
141
154
 
142
- # Save credentials to file
143
- auth.to_file(_AUTH_PATH)
155
+ for audiobook in response.get("products", []):
156
+ authors = [author["name"] for author in audiobook["authors"]]
157
+ wishlist_books.append(
158
+ Book(
159
+ retailer=self.name,
160
+ title=audiobook["title"],
161
+ authors=", ".join(authors),
162
+ list_price=1,
163
+ current_price=1,
164
+ timepoint=config.run_time,
165
+ format=self.format,
166
+ audiobook_isbn=audiobook["isbn"],
167
+ )
168
+ )
144
169
 
145
- self._auth = audible.Authenticator.from_file(_AUTH_PATH)
146
- self._client = audible.AsyncClient(auth=self._auth)
170
+ page += 1
171
+ total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
172
+
173
+ return wishlist_books
@@ -0,0 +1,182 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ from datetime import datetime, timedelta
5
+
6
+ import aiohttp
7
+ import click
8
+
9
+ from tbr_deal_finder import TBR_DEALS_PATH
10
+ from tbr_deal_finder.config import Config
11
+ from tbr_deal_finder.retailer.models import Retailer
12
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
13
+ from tbr_deal_finder.utils import currency_to_float, echo_err
14
+
15
+
16
+ class Chirp(Retailer):
17
+ # Static because url for other locales just redirects to .com
18
+ _url: str = "https://api.chirpbooks.com/api/graphql"
19
+ USER_AGENT = "ChirpBooks/5.13.9 (Android)"
20
+
21
+ def __init__(self):
22
+ self.auth_token = None
23
+
24
+ @property
25
+ def name(self) -> str:
26
+ return "Chirp"
27
+
28
+ @property
29
+ def format(self) -> BookFormat:
30
+ return BookFormat.AUDIOBOOK
31
+
32
+ async def make_request(self, request_type: str, **kwargs) -> dict:
33
+ headers = kwargs.pop("headers", {})
34
+ headers["Accept"] = "application/json"
35
+ headers["Content-Type"] = "application/json"
36
+ headers["User-Agent"] = self.USER_AGENT
37
+ if self.auth_token:
38
+ headers["authorization"] = f"Bearer {self.auth_token}"
39
+
40
+ async with aiohttp.ClientSession() as http_client:
41
+ response = await http_client.request(
42
+ request_type.upper(),
43
+ self._url,
44
+ headers=headers,
45
+ **kwargs
46
+ )
47
+ if response.ok:
48
+ return await response.json()
49
+ else:
50
+ return {}
51
+
52
+ async def set_auth(self):
53
+ auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
54
+ if os.path.exists(auth_path):
55
+ with open(auth_path, "r") as f:
56
+ auth_info = json.load(f)
57
+ if auth_info:
58
+ token_created_at = datetime.fromtimestamp(auth_info["created_at"])
59
+ max_token_age = datetime.now() - timedelta(days=5)
60
+ if token_created_at > max_token_age:
61
+ self.auth_token = auth_info["data"]["signIn"]["user"]["token"]
62
+ return
63
+
64
+ response = await self.make_request(
65
+ "POST",
66
+ json={
67
+ "query": "mutation signIn($email: String!, $password: String!) { signIn(email: $email, password: $password) { user { id token webToken email } } }",
68
+ "variables": {
69
+ "email": click.prompt("Chirp account email"),
70
+ "password": click.prompt("Chirp Password", hide_input=True),
71
+ }
72
+ }
73
+ )
74
+ if not response:
75
+ echo_err("Chirp login failed, please try again.")
76
+ await self.set_auth()
77
+
78
+ # Set token for future requests during the current execution
79
+ self.auth_token = response["data"]["signIn"]["user"]["token"]
80
+
81
+ response["created_at"] = datetime.now().timestamp()
82
+ with open(auth_path, "w") as f:
83
+ json.dump(response, f)
84
+
85
+ async def get_book(
86
+ self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
87
+ ) -> Book:
88
+ title = target.title
89
+ authors = target.authors
90
+ 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()
102
+
103
+ audiobooks = response_body["data"]["audiobooks"]["objects"]
104
+ if not audiobooks:
105
+ return Book(
106
+ retailer=self.name,
107
+ title=title,
108
+ authors=authors,
109
+ list_price=0,
110
+ current_price=0,
111
+ timepoint=runtime,
112
+ format=BookFormat.AUDIOBOOK,
113
+ exists=False,
114
+ )
115
+
116
+ for book in audiobooks:
117
+ if not book["currentProduct"]:
118
+ continue
119
+
120
+ normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
121
+ if (
122
+ book["displayTitle"] == title
123
+ and any(author in normalized_authors for author in target.normalized_authors)
124
+ ):
125
+ return Book(
126
+ retailer=self.name,
127
+ title=title,
128
+ authors=authors,
129
+ list_price=currency_to_float(book["currentProduct"]["listingPrice"]),
130
+ current_price=currency_to_float(book["currentProduct"]["discountPrice"]),
131
+ timepoint=runtime,
132
+ format=BookFormat.AUDIOBOOK,
133
+ )
134
+
135
+ return Book(
136
+ retailer=self.name,
137
+ title=title,
138
+ authors=target.authors,
139
+ list_price=0,
140
+ current_price=0,
141
+ timepoint=runtime,
142
+ format=BookFormat.AUDIOBOOK,
143
+ exists=False,
144
+ )
145
+
146
+ async def get_wishlist(self, config: Config) -> list[Book]:
147
+ wishlist_books = []
148
+ page = 1
149
+
150
+ while True:
151
+ response = await self.make_request(
152
+ "POST",
153
+ json={
154
+ "query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query FetchWishlistDealAudiobooks($page:Int,$pageSize:Int){currentUserWishlist{paginatedItems(filter:\"currently_promoted\",sort:\"promotion_end_date\",salability:current_or_future){totalCount objects(page:$page,pageSize:$pageSize){... on WishlistItem{id audiobook{...audiobookFields currentProduct{...productFields}}}}}}}",
155
+ "variables": {"page": page, "pageSize": 15},
156
+ "operationName": "FetchWishlistDealAudiobooks"
157
+ }
158
+ )
159
+
160
+ audiobooks = response.get(
161
+ "data", {}
162
+ ).get("currentUserWishlist", {}).get("paginatedItems", {}).get("objects", [])
163
+
164
+ if not audiobooks:
165
+ return wishlist_books
166
+
167
+ for book in audiobooks:
168
+ audiobook = book["audiobook"]
169
+ authors = [author["name"] for author in audiobook["allAuthors"]]
170
+ wishlist_books.append(
171
+ Book(
172
+ retailer=self.name,
173
+ title=audiobook["displayTitle"],
174
+ authors=", ".join(authors),
175
+ list_price=1,
176
+ current_price=1,
177
+ timepoint=config.run_time,
178
+ format=self.format,
179
+ )
180
+ )
181
+
182
+ page += 1
@@ -3,12 +3,12 @@ import json
3
3
  import os
4
4
  import urllib.parse
5
5
  from datetime import datetime, timedelta
6
- from typing import Union
7
6
 
8
7
  import aiohttp
9
8
  import click
10
9
 
11
10
  from tbr_deal_finder import TBR_DEALS_PATH
11
+ from tbr_deal_finder.config import Config
12
12
  from tbr_deal_finder.retailer.models import Retailer
13
13
  from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
14
14
  from tbr_deal_finder.utils import currency_to_float
@@ -33,11 +33,16 @@ class LibroFM(Retailer):
33
33
  def name(self) -> str:
34
34
  return "Libro.FM"
35
35
 
36
+ @property
37
+ def format(self) -> BookFormat:
38
+ return BookFormat.AUDIOBOOK
39
+
36
40
  async def make_request(self, url_path: str, request_type: str, **kwargs) -> dict:
37
41
  url = urllib.parse.urljoin(self.BASE_URL, url_path)
38
42
  headers = kwargs.pop("headers", {})
39
43
  headers["User-Agent"] = self.USER_AGENT
40
- headers["authorization"] = f"Bearer {self.auth_token}"
44
+ if self.auth_token:
45
+ headers["authorization"] = f"Bearer {self.auth_token}"
41
46
 
42
47
  async with aiohttp.ClientSession() as http_client:
43
48
  response = await http_client.request(
@@ -75,7 +80,9 @@ class LibroFM(Retailer):
75
80
  with open(auth_path, "w") as f:
76
81
  json.dump(response, f)
77
82
 
78
- async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Union[str, None]:
83
+ async def get_book_isbn(self, book: Book, runtime: datetime, semaphore: asyncio.Semaphore) -> Book:
84
+ # runtime isn't used but get_book_isbn must follow the get_book method signature.
85
+
79
86
  title = book.title
80
87
 
81
88
  async with semaphore:
@@ -90,15 +97,25 @@ class LibroFM(Retailer):
90
97
  )
91
98
 
92
99
  for b in response["audiobook_collection"]["audiobooks"]:
93
- if title == b["title"] and book.normalized_authors == get_normalized_authors(b["authors"]):
100
+ normalized_authors = get_normalized_authors(b["authors"])
101
+
102
+ if (
103
+ title == b["title"]
104
+ and any(author in normalized_authors for author in book.normalized_authors)
105
+ ):
94
106
  book.audiobook_isbn = b["isbn"]
95
- return book.audiobook_isbn
107
+ break
96
108
 
97
- return None
109
+ return book
98
110
 
99
111
  async def get_book(
100
112
  self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
101
113
  ) -> 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
+
102
119
  if not target.audiobook_isbn:
103
120
  return Book(
104
121
  retailer=self.name,
@@ -122,7 +139,7 @@ class LibroFM(Retailer):
122
139
  retailer=self.name,
123
140
  title=target.title,
124
141
  authors=target.authors,
125
- list_price=0,
142
+ list_price=target.audiobook_list_price,
126
143
  current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
127
144
  timepoint=runtime,
128
145
  format=BookFormat.AUDIOBOOK,
@@ -138,3 +155,37 @@ class LibroFM(Retailer):
138
155
  format=BookFormat.AUDIOBOOK,
139
156
  exists=False,
140
157
  )
158
+
159
+ async def get_wishlist(self, config: Config) -> list[Book]:
160
+ wishlist_books = []
161
+
162
+ page = 1
163
+ total_pages = 1
164
+ while page <= total_pages:
165
+ response = await self.make_request(
166
+ f"api/v10/explore/wishlist",
167
+ "GET",
168
+ params=dict(page=2)
169
+ )
170
+ wishlist = response.get("data", {}).get("wishlist", {})
171
+ if not wishlist:
172
+ return []
173
+
174
+ for book in wishlist.get("audiobooks", []):
175
+ wishlist_books.append(
176
+ Book(
177
+ retailer=self.name,
178
+ title=book["title"],
179
+ authors=", ".join(book["authors"]),
180
+ list_price=1,
181
+ current_price=1,
182
+ timepoint=config.run_time,
183
+ format=self.format,
184
+ audiobook_isbn=book["isbn"],
185
+ )
186
+ )
187
+
188
+ page += 1
189
+ total_pages = wishlist["total_pages"]
190
+
191
+ return wishlist_books
@@ -2,7 +2,8 @@ import abc
2
2
  import asyncio
3
3
  from datetime import datetime
4
4
 
5
- from tbr_deal_finder.book import Book
5
+ from tbr_deal_finder.book import Book, BookFormat
6
+ from tbr_deal_finder.config import Config
6
7
 
7
8
 
8
9
  class Retailer(abc.ABC):
@@ -12,6 +13,21 @@ class Retailer(abc.ABC):
12
13
  def name(self) -> str:
13
14
  raise NotImplementedError
14
15
 
16
+ @property
17
+ def format(self) -> BookFormat:
18
+ """The format of the books they sell.
19
+
20
+ For example,
21
+ Audible would be audiobooks
22
+ Kindle would be ebooks
23
+
24
+ :return:
25
+ """
26
+ raise NotImplementedError
27
+
28
+ async def set_auth(self):
29
+ raise NotImplementedError
30
+
15
31
  async def get_book(
16
32
  self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
17
33
  ) -> Book:
@@ -31,7 +47,7 @@ class Retailer(abc.ABC):
31
47
  """
32
48
  raise NotImplementedError
33
49
 
34
- async def set_auth(self):
50
+ async def get_wishlist(self, config: Config) -> list[Book]:
35
51
  raise NotImplementedError
36
52
 
37
53
 
@@ -6,9 +6,9 @@ import click
6
6
  import pandas as pd
7
7
  from tqdm.asyncio import tqdm_asyncio
8
8
 
9
- from tbr_deal_finder.book import Book, get_active_deals
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.library_exports import get_tbr_books
11
+ from tbr_deal_finder.tracked_books import get_tbr_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
@@ -73,7 +73,7 @@ def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
73
73
  async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
74
74
  """Get Books with limited concurrency.
75
75
 
76
- - Creates a semaphore to limit concurrent requests.
76
+ - Creates semaphore to limit concurrent requests.
77
77
  - Creates a list to store the response.
78
78
  - Creates a list to store unresolved books.
79
79
 
@@ -163,14 +163,23 @@ async def get_latest_deals(config: Config):
163
163
  """
164
164
 
165
165
  books: list[Book] = []
166
- tbr_books = get_tbr_books(config)
166
+ tbr_books = await get_tbr_books(config)
167
+
167
168
  for retailer_str in config.tracked_retailers:
168
169
  retailer = RETAILER_MAP[retailer_str]()
169
170
  await retailer.set_auth()
170
171
 
172
+ # Don't check on deals in a specified format
173
+ # that does not match the format the retailer sells
174
+ relevant_tbr_books = [
175
+ book
176
+ for book in tbr_books
177
+ if book.format == BookFormat.NA or book.format == retailer.format
178
+ ]
179
+
171
180
  echo_info(f"Getting deals from {retailer.name}")
172
181
  click.echo("\n---------------")
173
- books.extend(await _get_books(config, retailer, tbr_books))
182
+ books.extend(await _get_books(config, retailer, relevant_tbr_books))
174
183
  click.echo("---------------\n")
175
184
 
176
185
  _apply_proper_list_prices(books)
@@ -0,0 +1,120 @@
1
+ import asyncio
2
+ import csv
3
+
4
+ from tqdm.asyncio import tqdm_asyncio
5
+
6
+ from tbr_deal_finder.book import Book, BookFormat, get_title_id
7
+ from tbr_deal_finder.retailer import Chirp, RETAILER_MAP
8
+ from tbr_deal_finder.config import Config
9
+ from tbr_deal_finder.library_exports import (
10
+ get_book_authors,
11
+ get_book_title,
12
+ is_tbr_book,
13
+ requires_audiobook_list_price_default
14
+ )
15
+ from tbr_deal_finder.retailer.models import Retailer
16
+ from tbr_deal_finder.utils import currency_to_float
17
+
18
+
19
+ def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
20
+ """Adds tbr books in the library export to the provided tbr_book_map
21
+
22
+ :param config:
23
+ :param tbr_book_map:
24
+ :return:
25
+ """
26
+ for library_export_path in config.library_export_paths:
27
+
28
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
29
+ # Use csv.DictReader to get dictionaries with column headers
30
+ for book_dict in csv.DictReader(file):
31
+ if not is_tbr_book(book_dict):
32
+ continue
33
+
34
+ title = get_book_title(book_dict)
35
+ authors = get_book_authors(book_dict)
36
+
37
+ key = get_title_id(title, authors, BookFormat.NA)
38
+ if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
39
+ continue
40
+
41
+ tbr_book_map[key] = Book(
42
+ retailer="N/A",
43
+ title=title,
44
+ authors=authors,
45
+ list_price=0,
46
+ current_price=0,
47
+ timepoint=config.run_time,
48
+ format=BookFormat.NA,
49
+ audiobook_isbn=book_dict.get("audiobook_isbn"),
50
+ audiobook_list_price=currency_to_float(book_dict.get("audiobook_list_price") or 0),
51
+ )
52
+
53
+
54
+ async def _apply_dynamic_audiobook_list_price(config: Config, tbr_book_map: dict[str: Book]):
55
+ target_books = [
56
+ book
57
+ for book in tbr_book_map.values()
58
+ if book.format == BookFormat.AUDIOBOOK
59
+ ]
60
+ if not target_books:
61
+ return
62
+
63
+ chirp = Chirp()
64
+ semaphore = asyncio.Semaphore(5)
65
+ books_with_pricing: list[Book] = await tqdm_asyncio.gather(
66
+ *[
67
+ chirp.get_book(book, config.run_time, semaphore)
68
+ for book in target_books
69
+ ],
70
+ desc=f"Getting list prices for Libro.FM wishlist"
71
+ )
72
+ for book in books_with_pricing:
73
+ tbr_book_map[book.title_id].audiobook_list_price = book.list_price
74
+
75
+
76
+ async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
77
+ """Adds wishlist books in the library export to the provided tbr_book_map
78
+ Books added here has the format the retailer sells (e.g. Audiobook)
79
+ so deals are only checked for retailers with that type.
80
+
81
+ For example,
82
+ I as a user have Dune on my audible wishlist.
83
+ I want to see deals for it on Libro because it's an audiobook.
84
+ I don't want to see Kindle deals.
85
+
86
+ :param config:
87
+ :param tbr_book_map:
88
+ :return:
89
+ """
90
+ for retailer_str in config.tracked_retailers:
91
+ retailer: Retailer = RETAILER_MAP[retailer_str]()
92
+ await retailer.set_auth()
93
+
94
+ for book in (await retailer.get_wishlist(config)):
95
+ na_key = get_title_id(book.title, book.authors, BookFormat.NA)
96
+ if na_key in tbr_book_map:
97
+ continue
98
+
99
+ key = book.title_id
100
+ if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
101
+ continue
102
+
103
+ tbr_book_map[key] = book
104
+
105
+ if requires_audiobook_list_price_default(config):
106
+ await _apply_dynamic_audiobook_list_price(config, tbr_book_map)
107
+
108
+
109
+ async def get_tbr_books(config: Config) -> list[Book]:
110
+ tbr_book_map: dict[str: Book] = {}
111
+
112
+ # Get TBRs specified in the user library (StoryGraph/GoodReads) export
113
+ _library_export_tbr_books(config, tbr_book_map)
114
+
115
+ # Pull wishlist from tracked retailers
116
+ await _retailer_wishlist(config, tbr_book_map)
117
+
118
+ return list(tbr_book_map.values())
119
+
120
+
@@ -563,7 +563,7 @@ wheels = [
563
563
 
564
564
  [[package]]
565
565
  name = "tbr-deal-finder"
566
- version = "0.1.5"
566
+ version = "0.1.6"
567
567
  source = { editable = "." }
568
568
  dependencies = [
569
569
  { name = "aiohttp" },
@@ -1,12 +0,0 @@
1
-
2
- # Change Log
3
-
4
- ## 0.1.5 (July 30, 2025)
5
-
6
- Notes:
7
- * Added formatting to select messages to make the messages purpose clearer.
8
-
9
- BUG FIXES:
10
- * Fixed issue getting books from libro and chirp too aggressively
11
- * User must now track deals for at least one retailer
12
-
@@ -1,155 +0,0 @@
1
- import asyncio
2
- import csv
3
- import shutil
4
- import tempfile
5
-
6
- from tqdm.asyncio import tqdm_asyncio
7
-
8
- from tbr_deal_finder.book import Book, BookFormat
9
- from tbr_deal_finder.config import Config
10
- from tbr_deal_finder.retailer.librofm import LibroFM
11
-
12
-
13
- def _get_book_authors(book: dict) -> str:
14
- if authors := book.get('Authors'):
15
- return authors
16
-
17
- authors = book['Author']
18
- if additional_authors := book.get("Additional Authors"):
19
- authors = f"{authors}, {additional_authors}"
20
-
21
- return authors
22
-
23
-
24
- def _get_book_title(book: dict) -> str:
25
- title = book['Title']
26
- return title.split("(")[0].strip()
27
-
28
-
29
- def _is_tbr_book(book: dict) -> bool:
30
- if "Read Status" in book:
31
- return book["Read Status"] == "to-read"
32
- elif "Bookshelves" in book:
33
- return "to-read" in book["Bookshelves"]
34
- else:
35
- return True
36
-
37
-
38
- def get_tbr_books(config: Config) -> list[Book]:
39
- tbr_book_map: dict[str: Book] = {}
40
- for library_export_path in config.library_export_paths:
41
-
42
- with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
43
- # Use csv.DictReader to get dictionaries with column headers
44
- for book_dict in csv.DictReader(file):
45
- if not _is_tbr_book(book_dict):
46
- continue
47
-
48
- title = _get_book_title(book_dict)
49
- authors = _get_book_authors(book_dict)
50
- key = f'{title}__{authors}'
51
-
52
- if key in tbr_book_map:
53
- continue
54
-
55
- tbr_book_map[key] = Book(
56
- retailer="N/A",
57
- title=title,
58
- authors=authors,
59
- list_price=0,
60
- current_price=0,
61
- timepoint=config.run_time,
62
- format=BookFormat.NA,
63
- audiobook_isbn=book_dict["audiobook_isbn"],
64
- )
65
- return list(tbr_book_map.values())
66
-
67
-
68
- async def maybe_set_library_export_audiobook_isbn(config: Config):
69
- """To get the price from Libro.fm for a book you need its ISBN
70
-
71
- As opposed to trying to get that every time latest-deals is run
72
- we're just updating the export csv once to include the ISBN.
73
- """
74
-
75
- if "Libro.FM" not in config.tracked_retailers:
76
- return
77
-
78
- books_requiring_check_map = dict()
79
- book_to_isbn_map = dict()
80
-
81
-
82
- for library_export_path in config.library_export_paths:
83
- with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
84
- # Use csv.DictReader to get dictionaries with column headers
85
- for book_dict in csv.DictReader(file):
86
- if not _is_tbr_book(book_dict):
87
- continue
88
-
89
- title = _get_book_title(book_dict)
90
- authors = _get_book_authors(book_dict)
91
- key = f'{title}__{authors}'
92
-
93
- if "audiobook_isbn" in book_dict:
94
- book_to_isbn_map[key] = book_dict["audiobook_isbn"]
95
- books_requiring_check_map.pop(key, None)
96
- elif key not in book_to_isbn_map:
97
- books_requiring_check_map[key] = Book(
98
- retailer="N/A",
99
- title=title,
100
- authors=authors,
101
- list_price=0,
102
- current_price=0,
103
- timepoint=config.run_time,
104
- format=BookFormat.NA
105
- )
106
-
107
- if not books_requiring_check_map:
108
- return
109
-
110
- libro_fm = LibroFM()
111
- # Setting it lower to be a good user of libro on their more expensive search call
112
- semaphore = asyncio.Semaphore(3)
113
-
114
- # Set the audiobook isbn for Book instances in books_requiring_check_map
115
- await tqdm_asyncio.gather(
116
- *[
117
- libro_fm.get_book_isbn(book, semaphore) for book in books_requiring_check_map.values()
118
- ],
119
- desc="Getting required audiobook ISBN info"
120
- )
121
-
122
- # Go back and now add the audiobook_isbn
123
- for library_export_path in config.library_export_paths:
124
- with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
125
- reader = csv.DictReader(file)
126
- field_names = list(reader.fieldnames) + ["audiobook_isbn"]
127
- file_content = [book_dict for book_dict in reader]
128
- if not file_content or "audiobook_isbn" in file_content[0]:
129
- continue
130
-
131
- with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
132
- temp_filename = temp_file.name
133
- writer = csv.DictWriter(temp_file, fieldnames=field_names)
134
- writer.writeheader()
135
-
136
- for book_dict in file_content:
137
- if _is_tbr_book(book_dict):
138
- title = _get_book_title(book_dict)
139
- authors = _get_book_authors(book_dict)
140
- key = f'{title}__{authors}'
141
-
142
- if key in book_to_isbn_map:
143
- audiobook_isbn = book_to_isbn_map[key]
144
- else:
145
- book = books_requiring_check_map[key]
146
- audiobook_isbn = book.audiobook_isbn
147
-
148
- book_dict["audiobook_isbn"] = audiobook_isbn
149
- else:
150
- book_dict["audiobook_isbn"] = ""
151
-
152
- writer.writerow(book_dict)
153
-
154
- shutil.move(temp_filename, library_export_path)
155
-
@@ -1,80 +0,0 @@
1
- import asyncio
2
- from datetime import datetime
3
-
4
- import aiohttp
5
-
6
- from tbr_deal_finder.retailer.models import Retailer
7
- from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
8
- from tbr_deal_finder.utils import currency_to_float
9
-
10
-
11
- class Chirp(Retailer):
12
- # Static because url for other locales just redirects to .com
13
- _url: str = "https://www.chirpbooks.com/api/graphql"
14
-
15
- @property
16
- def name(self) -> str:
17
- return "Chirp"
18
-
19
- async def get_book(
20
- self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
21
- ) -> Book:
22
- title = target.title
23
- authors = target.authors
24
- async with semaphore:
25
- async with aiohttp.ClientSession() as http_client:
26
- response = await http_client.request(
27
- "POST",
28
- self._url,
29
- json={
30
- "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}}}}}",
31
- "variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
32
- "operationName": "AudiobookSearch"
33
- }
34
- )
35
- response_body = await response.json()
36
-
37
- audiobooks = response_body["data"]["audiobooks"]["objects"]
38
- if not audiobooks:
39
- return Book(
40
- retailer=self.name,
41
- title=title,
42
- authors=authors,
43
- list_price=0,
44
- current_price=0,
45
- timepoint=runtime,
46
- format=BookFormat.AUDIOBOOK,
47
- exists=False,
48
- )
49
-
50
- for book in audiobooks:
51
- if not book["currentProduct"]:
52
- continue
53
-
54
- if (
55
- book["displayTitle"] == title
56
- and get_normalized_authors(book["displayAuthors"]) == target.normalized_authors
57
- ):
58
- return Book(
59
- retailer=self.name,
60
- title=title,
61
- authors=authors,
62
- list_price=currency_to_float(book["currentProduct"]["listingPrice"]),
63
- current_price=currency_to_float(book["currentProduct"]["discountPrice"]),
64
- timepoint=runtime,
65
- format=BookFormat.AUDIOBOOK,
66
- )
67
-
68
- return Book(
69
- retailer=self.name,
70
- title=title,
71
- authors=target.authors,
72
- list_price=0,
73
- current_price=0,
74
- timepoint=runtime,
75
- format=BookFormat.AUDIOBOOK,
76
- exists=False,
77
- )
78
-
79
- async def set_auth(self):
80
- return
File without changes