tbr-deal-finder 0.1.3__py3-none-any.whl → 0.1.5__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.
tbr_deal_finder/cli.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import os
3
+ import sys
3
4
  from datetime import timedelta
4
5
  from textwrap import dedent
5
6
  from typing import Union
@@ -13,7 +14,14 @@ from tbr_deal_finder.migrations import make_migrations
13
14
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
14
15
  from tbr_deal_finder.retailer import RETAILER_MAP
15
16
  from tbr_deal_finder.retailer_deal import get_latest_deals
16
- from tbr_deal_finder.utils import get_duckdb_conn, get_query_by_name, execute_query
17
+ from tbr_deal_finder.utils import (
18
+ echo_err,
19
+ echo_info,
20
+ echo_success,
21
+ execute_query,
22
+ get_duckdb_conn,
23
+ get_query_by_name
24
+ )
17
25
 
18
26
 
19
27
  @click.group()
@@ -31,12 +39,12 @@ def _add_path(existing_paths: list[str]) -> Union[str, None]:
31
39
  try:
32
40
  new_path = os.path.expanduser(click.prompt("What is the new path"))
33
41
  if new_path in existing_paths:
34
- click.echo(f"{new_path} is already being tracked.\n")
42
+ echo_info(f"{new_path} is already being tracked.\n")
35
43
  return None
36
44
  elif os.path.exists(new_path):
37
45
  return new_path
38
46
  else:
39
- click.echo(f"Could not find {new_path}. Please try again.\n")
47
+ echo_err(f"Could not find {new_path}. Please try again.\n")
40
48
  return _add_path(existing_paths)
41
49
  except (KeyError, KeyboardInterrupt, TypeError):
42
50
  return None
@@ -116,14 +124,21 @@ def _set_locale(config: Config):
116
124
 
117
125
 
118
126
  def _set_tracked_retailers(config: Config):
119
- config.set_tracked_retailers(
120
- questionary.checkbox(
121
- "Select the retailers you want to check deals for. "
122
- "Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.",
127
+ while True:
128
+ 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",
123
131
  choices=[
124
132
  questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
125
133
  for retailer in RETAILER_MAP.keys()
126
- ]).ask()
134
+ ]).ask()
135
+ if len(user_response) > 1:
136
+ break
137
+ else:
138
+ echo_err("You must track deals for at least one retailer.")
139
+
140
+ config.set_tracked_retailers(
141
+ user_response
127
142
  )
128
143
 
129
144
 
@@ -133,10 +148,14 @@ def _set_config() -> Config:
133
148
  except FileNotFoundError:
134
149
  config = Config(library_export_paths=[], tracked_retailers=list(RETAILER_MAP.keys()))
135
150
 
136
- # Setting these config values are a little more involved,
137
- # so they're broken out into their own functions
138
- _set_library_export_paths(config)
139
- _set_tracked_retailers(config)
151
+ try:
152
+ # Config attrs that requires a user provided value
153
+ _set_library_export_paths(config)
154
+ _set_tracked_retailers(config)
155
+ except (KeyError, KeyboardInterrupt, TypeError):
156
+ echo_err("Config setup cancelled.")
157
+ sys.exit(0)
158
+
140
159
  _set_locale(config)
141
160
 
142
161
  config.max_price = click.prompt(
@@ -151,7 +170,7 @@ def _set_config() -> Config:
151
170
  )
152
171
 
153
172
  config.save()
154
- click.echo("Configuration saved!")
173
+ echo_success("Configuration saved!")
155
174
 
156
175
  return config
157
176
 
@@ -182,7 +201,7 @@ def latest_deals():
182
201
  except Exception as e:
183
202
  ran_successfully = False
184
203
  details = f"Error getting deals: {e}"
185
- click.echo(details)
204
+ echo_err(details)
186
205
  else:
187
206
  ran_successfully = True
188
207
  details = ""
@@ -198,7 +217,7 @@ def latest_deals():
198
217
  return
199
218
 
200
219
  else:
201
- click.echo(dedent("""
220
+ echo_info(dedent("""
202
221
  To prevent abuse lastest deals can only be pulled every 8 hours.
203
222
  Fetching most recent deal results.\n
204
223
  """))
@@ -207,7 +226,7 @@ def latest_deals():
207
226
  if books := get_deals_found_at(config.run_time):
208
227
  print_books(books)
209
228
  else:
210
- click.echo("No new deals found.")
229
+ echo_info("No new deals found.")
211
230
 
212
231
 
213
232
  @cli.command()
@@ -216,7 +235,7 @@ def active_deals():
216
235
  if books := get_active_deals():
217
236
  print_books(books)
218
237
  else:
219
- click.echo("No deals found.")
238
+ echo_info("No deals found.")
220
239
 
221
240
 
222
241
  if __name__ == '__main__':
@@ -7,7 +7,7 @@ import readline # type: ignore
7
7
 
8
8
  import audible
9
9
  import click
10
- from audible.login import playwright_external_login_url_callback
10
+ from audible.login import build_init_cookies
11
11
 
12
12
  from tbr_deal_finder import TBR_DEALS_PATH
13
13
  from tbr_deal_finder.config import Config
@@ -19,10 +19,41 @@ _AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
19
19
 
20
20
  def login_url_callback(url: str) -> str:
21
21
  """Helper function for login with external browsers."""
22
+
22
23
  try:
23
- return playwright_external_login_url_callback(url)
24
+ from playwright.sync_api import sync_playwright # type: ignore
25
+ use_playwright = True
24
26
  except ImportError:
25
- pass
27
+ use_playwright = False
28
+
29
+ if use_playwright:
30
+ with sync_playwright() as p:
31
+ iphone = p.devices["iPhone 12 Pro"]
32
+ browser = p.webkit.launch(headless=False)
33
+ context = browser.new_context(
34
+ **iphone
35
+ )
36
+ cookies = []
37
+ for name, value in build_init_cookies().items():
38
+ cookies.append(
39
+ {
40
+ "name": name,
41
+ "value": value,
42
+ "url": url
43
+ }
44
+ )
45
+ context.add_cookies(cookies)
46
+ page = browser.new_page()
47
+ page.goto(url)
48
+
49
+ while True:
50
+ page.wait_for_timeout(600)
51
+ if "/ap/maplanding" in page.url:
52
+ response_url = page.url
53
+ break
54
+
55
+ browser.close()
56
+ return response_url
26
57
 
27
58
  message = f"""\
28
59
  Please copy the following url and insert it into a web browser of your choice to log into Amazon.
@@ -21,18 +21,19 @@ class Chirp(Retailer):
21
21
  ) -> Book:
22
22
  title = target.title
23
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()
24
36
 
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"]
37
38
  if not audiobooks:
38
39
  return Book(
@@ -77,15 +77,17 @@ class LibroFM(Retailer):
77
77
 
78
78
  async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Union[str, None]:
79
79
  title = book.title
80
- response = await self.make_request(
81
- f"api/v10/explore/search",
82
- "GET",
83
- params={
84
- "q": title,
85
- "searchby": "titles",
86
- "sortby": "relevance#results",
87
- },
88
- )
80
+
81
+ async with semaphore:
82
+ response = await self.make_request(
83
+ f"api/v10/explore/search",
84
+ "GET",
85
+ params={
86
+ "q": title,
87
+ "searchby": "titles",
88
+ "sortby": "relevance#results",
89
+ },
90
+ )
89
91
 
90
92
  for b in response["audiobook_collection"]["audiobooks"]:
91
93
  if title == b["title"] and book.normalized_authors == get_normalized_authors(b["authors"]):
@@ -108,10 +110,12 @@ class LibroFM(Retailer):
108
110
  format=BookFormat.AUDIOBOOK,
109
111
  exists=False,
110
112
  )
111
- response = await self.make_request(
112
- f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
113
- "GET"
114
- )
113
+
114
+ async with semaphore:
115
+ response = await self.make_request(
116
+ f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
117
+ "GET"
118
+ )
115
119
 
116
120
  if response:
117
121
  return Book(
@@ -11,7 +11,7 @@ from tbr_deal_finder.config import Config
11
11
  from tbr_deal_finder.library_exports 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
- from tbr_deal_finder.utils import get_duckdb_conn
14
+ from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
15
15
 
16
16
 
17
17
  def update_retailer_deal_table(config: Config, new_deals: list[Book]):
@@ -41,7 +41,7 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
41
41
  # Any remaining values in active_deal_map mean that
42
42
  # it wasn't found and should be marked for deletion
43
43
  for deal in active_deal_map.values():
44
- click.echo(f"{str(deal)} is no longer active")
44
+ echo_warning(f"{str(deal)} is no longer active")
45
45
  deal.timepoint = config.run_time
46
46
  deal.deleted = True
47
47
  df_data.append(deal.dict())
@@ -101,12 +101,12 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
101
101
  unresolved_books.append(book)
102
102
 
103
103
  if retry_books := _retry_books(response, books):
104
- click.echo("Attempting to find missing books with alternate title")
104
+ echo_info("Attempting to find missing books with alternate title")
105
105
  response.extend(await _get_books(config, retailer, retry_books))
106
106
  elif unresolved_books:
107
107
  click.echo()
108
108
  for book in unresolved_books:
109
- click.echo(f"{book.title} by {book.authors} not found")
109
+ echo_info(f"{book.title} by {book.authors} not found")
110
110
 
111
111
  return response
112
112
 
@@ -168,7 +168,7 @@ async def get_latest_deals(config: Config):
168
168
  retailer = RETAILER_MAP[retailer_str]()
169
169
  await retailer.set_auth()
170
170
 
171
- click.echo(f"Getting deals from {retailer.name}")
171
+ echo_info(f"Getting deals from {retailer.name}")
172
172
  click.echo("\n---------------")
173
173
  books.extend(await _get_books(config, retailer, tbr_books))
174
174
  click.echo("---------------\n")
tbr_deal_finder/utils.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import re
2
2
  from typing import Optional
3
3
 
4
+ import click
4
5
  import duckdb
5
6
 
6
7
  from tbr_deal_finder import TBR_DEALS_PATH, QUERY_PATH
@@ -38,3 +39,19 @@ def execute_query(
38
39
 
39
40
  def get_query_by_name(file_name: str) -> str:
40
41
  return QUERY_PATH.joinpath(file_name).read_text()
42
+
43
+
44
+ def echo_err(message):
45
+ click.secho(f'\n❌ {message}\n', fg='red', bold=True)
46
+
47
+
48
+ def echo_success(message):
49
+ click.secho(f'\n✅ {message}', fg='green', bold=True)
50
+
51
+
52
+ def echo_warning(message):
53
+ click.secho(f'\n⚠️ {message}', fg='yellow')
54
+
55
+
56
+ def echo_info(message):
57
+ click.secho(f'{message}', fg='blue')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tbr-deal-finder
3
- Version: 0.1.3
3
+ Version: 0.1.5
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
@@ -32,7 +32,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
32
32
  ### Audiobooks
33
33
  * Audible
34
34
  * Chirp
35
- * Libro.fm (Work in progress)
35
+ * Libro.fm
36
36
 
37
37
  ### Locales
38
38
  * US
@@ -106,7 +106,7 @@ tbr-deal-finder setup
106
106
 
107
107
  #### UV
108
108
  ```sh
109
- uv run -m tbr_deal_finder.main setup
109
+ uv run -m tbr_deal_finder.cli setup
110
110
  ```
111
111
 
112
112
  You will be prompted to:
@@ -131,7 +131,7 @@ tbr-deal-finder [COMMAND]
131
131
 
132
132
  #### UV
133
133
  ```sh
134
- uv run -m tbr_deal_finder.main [COMMAND]
134
+ uv run -m tbr_deal_finder.cli [COMMAND]
135
135
  ```
136
136
 
137
137
  Example:
@@ -140,7 +140,7 @@ tbr-deal-finder latest-deals
140
140
 
141
141
  # or
142
142
 
143
- uv run -m tbr_deal_finder.main latest-deals
143
+ uv run -m tbr_deal_finder.cli latest-deals
144
144
  ```
145
145
 
146
146
  ## Updating your TBR
@@ -1,21 +1,21 @@
1
1
  tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
2
2
  tbr_deal_finder/book.py,sha256=2MQirkxDIVKdQQ07U56zwOw45rC8KH-5aC932_X9dhE,3333
3
- tbr_deal_finder/cli.py,sha256=8riqTK5QVPKyP3aUQ3hHjNbIuZ0ibF_WLiNFMBqddT4,6830
3
+ tbr_deal_finder/cli.py,sha256=1O_smeeIJSLWBg5vzSGIv-LDU8RYXHIBnrX_wOhuIKM,7168
4
4
  tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
5
5
  tbr_deal_finder/library_exports.py,sha256=FJXBQV_9H5AI_cwNuGjvGQxgQ1RLlpldmDBwc_PsiK4,5474
6
6
  tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
7
- tbr_deal_finder/retailer_deal.py,sha256=J3dGceB84wDQUV0FsW_0I0PLA5nU6LStzAx6sMNTo2c,6408
8
- tbr_deal_finder/utils.py,sha256=c_AfIpfE1IAewUBiaRhPHjBM2o-fvuVVcWfM7jPEOvk,1021
7
+ tbr_deal_finder/retailer_deal.py,sha256=mo3LkWWn6KENgaL9ZnIO24l-T43HZHbKLPG_D0dOsqU,6432
8
+ tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
9
9
  tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
10
10
  tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
11
11
  tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
12
12
  tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
13
- tbr_deal_finder/retailer/audible.py,sha256=7QYkaZOlImYOiBUo4zqhqwMEQVLMR895sk4rK7qpO3g,3478
14
- tbr_deal_finder/retailer/chirp.py,sha256=mi2uIhiXHxMnlRgtd1BZULEZbpF_K2HzxWubV4v_vvc,3540
15
- tbr_deal_finder/retailer/librofm.py,sha256=M4WvGh3Gf3LVUE3KOCVtNKJB8koQasgybUFhKBvqBe0,4476
13
+ tbr_deal_finder/retailer/audible.py,sha256=7z7rDQBcCwOhdATU4BJjsJ-QBP0HnxscMSGPmzN_K2k,4408
14
+ tbr_deal_finder/retailer/chirp.py,sha256=_ckdNPQT39Qy6ixI7sZLpEEDDgbxZn6_t4pQOiCULiE,3614
15
+ tbr_deal_finder/retailer/librofm.py,sha256=YZojG8B0WftQFphJjNWvMSBVVd8oGsg8cr00jKwt9xw,4590
16
16
  tbr_deal_finder/retailer/models.py,sha256=zEwyM_0ildB8p38sxfpE6p2dIvtwAjeaul-GkJlk2Fo,1012
17
- tbr_deal_finder-0.1.3.dist-info/METADATA,sha256=wguRn_cH0FpnBBEH1bqqzqkze6FYHaTNg7mSezzABTM,4160
18
- tbr_deal_finder-0.1.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- tbr_deal_finder-0.1.3.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
20
- tbr_deal_finder-0.1.3.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
21
- tbr_deal_finder-0.1.3.dist-info/RECORD,,
17
+ tbr_deal_finder-0.1.5.dist-info/METADATA,sha256=d9sfj3B4vbzXeysL_xHsKvoCUo15SI1x2meI-tow0Cg,4138
18
+ tbr_deal_finder-0.1.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ tbr_deal_finder-0.1.5.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
20
+ tbr_deal_finder-0.1.5.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
21
+ tbr_deal_finder-0.1.5.dist-info/RECORD,,