tbr-deal-finder 0.1.3__tar.gz → 0.1.5__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.
- tbr_deal_finder-0.1.5/CHANGELOG.md +12 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/PKG-INFO +5 -5
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/README.md +4 -4
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/pyproject.toml +1 -1
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/cli.py +36 -17
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/retailer/audible.py +34 -3
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/retailer/chirp.py +12 -11
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/retailer/librofm.py +17 -13
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/retailer_deal.py +5 -5
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/utils.py +17 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/uv.lock +1 -1
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/.github/workflows/publish-to-pypi.yaml +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/.gitignore +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/.python-version +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/LICENSE +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/__init__.py +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/book.py +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/config.py +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/library_exports.py +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/migrations.py +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/retailer/__init__.py +0 -0
- {tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/retailer/models.py +0 -0
@@ -0,0 +1,12 @@
|
|
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,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1.
|
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
|
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.
|
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.
|
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.
|
143
|
+
uv run -m tbr_deal_finder.cli latest-deals
|
144
144
|
```
|
145
145
|
|
146
146
|
## Updating your TBR
|
@@ -15,7 +15,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
15
15
|
### Audiobooks
|
16
16
|
* Audible
|
17
17
|
* Chirp
|
18
|
-
* Libro.fm
|
18
|
+
* Libro.fm
|
19
19
|
|
20
20
|
### Locales
|
21
21
|
* US
|
@@ -89,7 +89,7 @@ tbr-deal-finder setup
|
|
89
89
|
|
90
90
|
#### UV
|
91
91
|
```sh
|
92
|
-
uv run -m tbr_deal_finder.
|
92
|
+
uv run -m tbr_deal_finder.cli setup
|
93
93
|
```
|
94
94
|
|
95
95
|
You will be prompted to:
|
@@ -114,7 +114,7 @@ tbr-deal-finder [COMMAND]
|
|
114
114
|
|
115
115
|
#### UV
|
116
116
|
```sh
|
117
|
-
uv run -m tbr_deal_finder.
|
117
|
+
uv run -m tbr_deal_finder.cli [COMMAND]
|
118
118
|
```
|
119
119
|
|
120
120
|
Example:
|
@@ -123,7 +123,7 @@ tbr-deal-finder latest-deals
|
|
123
123
|
|
124
124
|
# or
|
125
125
|
|
126
|
-
uv run -m tbr_deal_finder.
|
126
|
+
uv run -m tbr_deal_finder.cli latest-deals
|
127
127
|
```
|
128
128
|
|
129
129
|
## Updating your TBR
|
@@ -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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
24
|
+
from playwright.sync_api import sync_playwright # type: ignore
|
25
|
+
use_playwright = True
|
24
26
|
except ImportError:
|
25
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
"
|
85
|
-
|
86
|
-
|
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
|
-
|
112
|
-
|
113
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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")
|
@@ -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')
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/queries/get_active_deals.sql
RENAMED
File without changes
|
{tbr_deal_finder-0.1.3 → tbr_deal_finder-0.1.5}/tbr_deal_finder/queries/get_deals_found_at.sql
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|