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.
- tbr_deal_finder-0.1.6/CHANGELOG.md +21 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/PKG-INFO +3 -2
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/README.md +2 -1
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/pyproject.toml +1 -1
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/book.py +18 -5
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/cli.py +10 -5
- tbr_deal_finder-0.1.6/tbr_deal_finder/library_exports.py +205 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/audible.py +53 -26
- tbr_deal_finder-0.1.6/tbr_deal_finder/retailer/chirp.py +182 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/librofm.py +58 -7
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/models.py +18 -2
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer_deal.py +14 -5
- tbr_deal_finder-0.1.6/tbr_deal_finder/tracked_books.py +120 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/uv.lock +1 -1
- tbr_deal_finder-0.1.5/CHANGELOG.md +0 -12
- tbr_deal_finder-0.1.5/tbr_deal_finder/library_exports.py +0 -155
- tbr_deal_finder-0.1.5/tbr_deal_finder/retailer/chirp.py +0 -80
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/.github/workflows/publish-to-pypi.yaml +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/.gitignore +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/.python-version +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/LICENSE +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/__init__.py +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/config.py +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/migrations.py +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
- {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
- {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/__init__.py +0 -0
- {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.
|
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
|
@@ -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.
|
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
|
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) >
|
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(
|
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
|
-
|
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
|
-
|
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
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
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
|
-
|
143
|
-
|
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
|
-
|
146
|
-
|
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
|
-
|
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) ->
|
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
|
-
|
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
|
-
|
107
|
+
break
|
96
108
|
|
97
|
-
return
|
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=
|
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
|
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.
|
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
|
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,
|
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
|
+
|
@@ -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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_active_deals.sql
RENAMED
File without changes
|
{tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_deals_found_at.sql
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|