tbr-deal-finder 0.1.7__tar.gz → 0.2.0__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.7 → tbr_deal_finder-0.2.0}/CHANGELOG.md +29 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/PKG-INFO +5 -2
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/README.md +4 -1
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/pyproject.toml +1 -1
- tbr_deal_finder-0.2.0/tbr_deal_finder/book.py +206 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/cli.py +5 -3
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/config.py +10 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/migrations.py +16 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/queries/get_active_deals.sql +1 -1
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/queries/get_deals_found_at.sql +1 -1
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/retailer/__init__.py +2 -0
- tbr_deal_finder-0.2.0/tbr_deal_finder/retailer/amazon.py +85 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/retailer/audible.py +13 -100
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/retailer/chirp.py +34 -55
- tbr_deal_finder-0.2.0/tbr_deal_finder/retailer/kindle.py +141 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/retailer/librofm.py +27 -53
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/retailer/models.py +37 -2
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/retailer_deal.py +9 -19
- tbr_deal_finder-0.2.0/tbr_deal_finder/tracked_books.py +339 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/uv.lock +1 -1
- tbr_deal_finder-0.1.7/tbr_deal_finder/book.py +0 -155
- tbr_deal_finder-0.1.7/tbr_deal_finder/library_exports.py +0 -208
- tbr_deal_finder-0.1.7/tbr_deal_finder/tracked_books.py +0 -120
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/.github/workflows/publish-to-pypi.yaml +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/.gitignore +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/.python-version +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/DESIGN.md +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/LICENSE +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/__init__.py +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/owned_books.py +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
- {tbr_deal_finder-0.1.7 → tbr_deal_finder-0.2.0}/tbr_deal_finder/utils.py +0 -0
@@ -1,6 +1,35 @@
|
|
1
1
|
|
2
2
|
# Change Log
|
3
3
|
|
4
|
+
---
|
5
|
+
|
6
|
+
## 0.2.0 (August 15, 2025)
|
7
|
+
|
8
|
+
Notes:
|
9
|
+
* Added foundational Kindle support
|
10
|
+
* Library support is undecided right now
|
11
|
+
* Unable to find the endpoint
|
12
|
+
* Wishlist support is undecided right now
|
13
|
+
* Unable to find the endpoint
|
14
|
+
* Improvements to title matching for Audible & Chirp
|
15
|
+
* Improved request performance for Chirp & Libro
|
16
|
+
|
17
|
+
BUG FIXES:
|
18
|
+
* Fixed breaking import on Windows systems
|
19
|
+
|
20
|
+
---
|
21
|
+
|
22
|
+
## 0.1.8 (August 13, 2025)
|
23
|
+
|
24
|
+
Notes:
|
25
|
+
* Improved performance for tracking on libro
|
26
|
+
* Preparing EBook support
|
27
|
+
|
28
|
+
BUG FIXES:
|
29
|
+
* Fixed initial login issue in libro.fm
|
30
|
+
|
31
|
+
---
|
32
|
+
|
4
33
|
## 0.1.7 (July 31, 2025)
|
5
34
|
|
6
35
|
Notes:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.0
|
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
|
@@ -37,6 +37,9 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
37
37
|
* Chirp
|
38
38
|
* Libro.fm
|
39
39
|
|
40
|
+
### EBooks
|
41
|
+
* Kindle
|
42
|
+
|
40
43
|
### Locales
|
41
44
|
* US
|
42
45
|
* CA
|
@@ -56,7 +59,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
56
59
|
1. If it's not already on your computer, download Python https://www.python.org/downloads/
|
57
60
|
1. tbr-deal-finder requires Python3.13 or higher
|
58
61
|
2. Optional: Install and use virtualenv
|
59
|
-
3. Open your Terminal/
|
62
|
+
3. Open your Terminal/Command Prompt
|
60
63
|
4. Run `pip3.13 install tbr-deal-finder`
|
61
64
|
|
62
65
|
### UV
|
@@ -19,6 +19,9 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
19
19
|
* Chirp
|
20
20
|
* Libro.fm
|
21
21
|
|
22
|
+
### EBooks
|
23
|
+
* Kindle
|
24
|
+
|
22
25
|
### Locales
|
23
26
|
* US
|
24
27
|
* CA
|
@@ -38,7 +41,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
38
41
|
1. If it's not already on your computer, download Python https://www.python.org/downloads/
|
39
42
|
1. tbr-deal-finder requires Python3.13 or higher
|
40
43
|
2. Optional: Install and use virtualenv
|
41
|
-
3. Open your Terminal/
|
44
|
+
3. Open your Terminal/Command Prompt
|
42
45
|
4. Run `pip3.13 install tbr-deal-finder`
|
43
46
|
|
44
47
|
### UV
|
@@ -0,0 +1,206 @@
|
|
1
|
+
import re
|
2
|
+
from datetime import datetime
|
3
|
+
from enum import Enum
|
4
|
+
from typing import Union
|
5
|
+
|
6
|
+
import click
|
7
|
+
from Levenshtein import ratio
|
8
|
+
from unidecode import unidecode
|
9
|
+
|
10
|
+
from tbr_deal_finder.config import Config
|
11
|
+
from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name, echo_info
|
12
|
+
|
13
|
+
_AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
|
14
|
+
|
15
|
+
class BookFormat(Enum):
|
16
|
+
AUDIOBOOK = "Audiobook"
|
17
|
+
EBOOK = "E-Book"
|
18
|
+
NA = "N/A" # When the format doesn't matter
|
19
|
+
|
20
|
+
|
21
|
+
class Book:
|
22
|
+
|
23
|
+
def __init__(
|
24
|
+
self,
|
25
|
+
retailer: str,
|
26
|
+
title: str,
|
27
|
+
authors: str,
|
28
|
+
timepoint: datetime,
|
29
|
+
format: Union[BookFormat, str],
|
30
|
+
list_price: float = 0,
|
31
|
+
current_price: float = 0,
|
32
|
+
ebook_asin: str = None,
|
33
|
+
audiobook_isbn: str = None,
|
34
|
+
audiobook_list_price: float = 0,
|
35
|
+
deleted: bool = False,
|
36
|
+
exists: bool = True,
|
37
|
+
):
|
38
|
+
self.retailer = retailer
|
39
|
+
self.title = get_normalized_title(title)
|
40
|
+
self.authors = authors
|
41
|
+
self.timepoint = timepoint
|
42
|
+
|
43
|
+
self.ebook_asin = ebook_asin
|
44
|
+
self.audiobook_isbn = audiobook_isbn
|
45
|
+
self.audiobook_list_price = audiobook_list_price
|
46
|
+
self.deleted = deleted
|
47
|
+
self.exists = exists
|
48
|
+
|
49
|
+
self.list_price = list_price
|
50
|
+
self.current_price = current_price
|
51
|
+
self.normalized_authors = get_normalized_authors(authors)
|
52
|
+
|
53
|
+
if isinstance(format, str):
|
54
|
+
format = BookFormat(format)
|
55
|
+
self.format = format
|
56
|
+
|
57
|
+
def discount(self) -> int:
|
58
|
+
return int((self.list_price/self.current_price - 1) * 100)
|
59
|
+
|
60
|
+
@staticmethod
|
61
|
+
def price_to_string(price: float) -> str:
|
62
|
+
return f"{Config.currency_symbol()}{price:.2f}"
|
63
|
+
|
64
|
+
@property
|
65
|
+
def deal_id(self) -> str:
|
66
|
+
return f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
67
|
+
|
68
|
+
@property
|
69
|
+
def title_id(self) -> str:
|
70
|
+
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
71
|
+
|
72
|
+
@property
|
73
|
+
def full_title_str(self) -> str:
|
74
|
+
return f"{self.title}__{self.normalized_authors}"
|
75
|
+
|
76
|
+
@property
|
77
|
+
def current_price(self) -> float:
|
78
|
+
return self._current_price
|
79
|
+
|
80
|
+
@current_price.setter
|
81
|
+
def current_price(self, price: float):
|
82
|
+
self._current_price = round(price, 2)
|
83
|
+
|
84
|
+
@property
|
85
|
+
def list_price(self) -> float:
|
86
|
+
return self._list_price
|
87
|
+
|
88
|
+
@list_price.setter
|
89
|
+
def list_price(self, price: float):
|
90
|
+
self._list_price = round(price, 2)
|
91
|
+
|
92
|
+
def list_price_string(self):
|
93
|
+
return self.price_to_string(self.list_price)
|
94
|
+
|
95
|
+
def current_price_string(self):
|
96
|
+
return self.price_to_string(self.current_price)
|
97
|
+
|
98
|
+
def __str__(self) -> str:
|
99
|
+
price = self.current_price_string()
|
100
|
+
book_format = self.format.value
|
101
|
+
title = self.title
|
102
|
+
if len(self.title) > 75:
|
103
|
+
title = f"{title[:75]}..."
|
104
|
+
return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer}"
|
105
|
+
|
106
|
+
def dict(self):
|
107
|
+
return {
|
108
|
+
"retailer": self.retailer,
|
109
|
+
"title": self.title,
|
110
|
+
"authors": self.authors,
|
111
|
+
"list_price": self.list_price,
|
112
|
+
"current_price": self.current_price,
|
113
|
+
"timepoint": self.timepoint,
|
114
|
+
"format": self.format.value,
|
115
|
+
"deleted": self.deleted,
|
116
|
+
"deal_id": self.deal_id,
|
117
|
+
}
|
118
|
+
|
119
|
+
def tbr_dict(self):
|
120
|
+
return {
|
121
|
+
"title": self.title,
|
122
|
+
"authors": self.authors,
|
123
|
+
"format": self.format.value,
|
124
|
+
"ebook_asin": self.ebook_asin,
|
125
|
+
"audiobook_isbn": self.audiobook_isbn,
|
126
|
+
"audiobook_list_price": self.audiobook_list_price,
|
127
|
+
"book_id": self.title_id,
|
128
|
+
}
|
129
|
+
|
130
|
+
|
131
|
+
def get_deals_found_at(timepoint: datetime) -> list[Book]:
|
132
|
+
db_conn = get_duckdb_conn()
|
133
|
+
query_response = execute_query(
|
134
|
+
db_conn,
|
135
|
+
get_query_by_name("get_deals_found_at.sql"),
|
136
|
+
{"timepoint": timepoint}
|
137
|
+
)
|
138
|
+
return [Book(**book) for book in query_response]
|
139
|
+
|
140
|
+
|
141
|
+
def get_active_deals() -> list[Book]:
|
142
|
+
db_conn = get_duckdb_conn()
|
143
|
+
query_response = execute_query(
|
144
|
+
db_conn,
|
145
|
+
get_query_by_name("get_active_deals.sql")
|
146
|
+
)
|
147
|
+
return [Book(**book) for book in query_response]
|
148
|
+
|
149
|
+
|
150
|
+
def print_books(books: list[Book]):
|
151
|
+
audiobooks = [book for book in books if book.format == BookFormat.AUDIOBOOK]
|
152
|
+
audiobooks = sorted(audiobooks, key=lambda book: book.deal_id)
|
153
|
+
|
154
|
+
ebooks = [book for book in books if book.format == BookFormat.EBOOK]
|
155
|
+
ebooks = sorted(ebooks, key=lambda book: book.deal_id)
|
156
|
+
|
157
|
+
for books_in_format in [audiobooks, ebooks]:
|
158
|
+
if not books_in_format:
|
159
|
+
continue
|
160
|
+
|
161
|
+
init_book = books_in_format[0]
|
162
|
+
echo_info(f"\n\n{init_book.format.value} Deals:")
|
163
|
+
|
164
|
+
prior_title_id = init_book.title_id
|
165
|
+
for book in books_in_format:
|
166
|
+
if prior_title_id != book.title_id:
|
167
|
+
prior_title_id = book.title_id
|
168
|
+
click.echo()
|
169
|
+
|
170
|
+
click.echo(str(book))
|
171
|
+
|
172
|
+
|
173
|
+
def get_full_title_str(title: str, authors: Union[list, str]) -> str:
|
174
|
+
return f"{title}__{get_normalized_authors(authors)}"
|
175
|
+
|
176
|
+
|
177
|
+
def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
|
178
|
+
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
179
|
+
|
180
|
+
|
181
|
+
def get_normalized_title(title: str) -> str:
|
182
|
+
return title.split(":")[0].split("(")[0].strip()
|
183
|
+
|
184
|
+
|
185
|
+
def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
186
|
+
if isinstance(authors, str):
|
187
|
+
authors = [i for i in authors.split(",")]
|
188
|
+
|
189
|
+
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
190
|
+
|
191
|
+
|
192
|
+
def is_matching_authors(a1: list[str], a2: list[str]) -> bool:
|
193
|
+
"""Checks if two normalized authors are matching.
|
194
|
+
Matching here means that they are at least 80% similar using levenshtein distance.
|
195
|
+
|
196
|
+
Score is calculated as follows:
|
197
|
+
1 - (distance / (len1 + len2))
|
198
|
+
|
199
|
+
:param a1:
|
200
|
+
:param a2:
|
201
|
+
:return:
|
202
|
+
"""
|
203
|
+
return any(
|
204
|
+
any(ratio(author1, author2, score_cutoff=.8) for author2 in a2)
|
205
|
+
for author1 in a1
|
206
|
+
)
|
@@ -9,11 +9,11 @@ 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_enrich_library_exports
|
13
12
|
from tbr_deal_finder.migrations import make_migrations
|
14
13
|
from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
|
15
14
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
16
15
|
from tbr_deal_finder.retailer_deal import get_latest_deals
|
16
|
+
from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books
|
17
17
|
from tbr_deal_finder.utils import (
|
18
18
|
echo_err,
|
19
19
|
echo_info,
|
@@ -180,6 +180,10 @@ def _set_config() -> Config:
|
|
180
180
|
def setup():
|
181
181
|
_set_config()
|
182
182
|
|
183
|
+
# Retailers may have changed causing some books to need reprocessing
|
184
|
+
config = Config.load()
|
185
|
+
reprocess_incomplete_tbr_books(config)
|
186
|
+
|
183
187
|
|
184
188
|
@cli.command()
|
185
189
|
def latest_deals():
|
@@ -189,8 +193,6 @@ def latest_deals():
|
|
189
193
|
except FileNotFoundError:
|
190
194
|
config = _set_config()
|
191
195
|
|
192
|
-
asyncio.run(maybe_enrich_library_exports(config))
|
193
|
-
|
194
196
|
db_conn = get_duckdb_conn()
|
195
197
|
results = execute_query(
|
196
198
|
db_conn,
|
@@ -83,6 +83,16 @@ class Config:
|
|
83
83
|
def tracked_retailers_str(self) -> str:
|
84
84
|
return ", ".join(self.tracked_retailers)
|
85
85
|
|
86
|
+
def is_tracking_format(self, book_format) -> bool:
|
87
|
+
from tbr_deal_finder.retailer import RETAILER_MAP
|
88
|
+
|
89
|
+
for retailer_str in self.tracked_retailers:
|
90
|
+
retailer = RETAILER_MAP[retailer_str]()
|
91
|
+
if retailer.format == book_format:
|
92
|
+
return True
|
93
|
+
|
94
|
+
return False
|
95
|
+
|
86
96
|
def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
|
87
97
|
if not library_export_paths:
|
88
98
|
self.library_export_paths = []
|
@@ -46,6 +46,22 @@ _MIGRATIONS = [
|
|
46
46
|
);
|
47
47
|
"""
|
48
48
|
),
|
49
|
+
TableMigration(
|
50
|
+
version=1,
|
51
|
+
table_name="tbr_book",
|
52
|
+
sql="""
|
53
|
+
CREATE TABLE tbr_book
|
54
|
+
(
|
55
|
+
title VARCHAR,
|
56
|
+
authors VARCHAR,
|
57
|
+
format VARCHAR,
|
58
|
+
ebook_asin VARCHAR,
|
59
|
+
audiobook_isbn VARCHAR,
|
60
|
+
audiobook_list_price FLOAT,
|
61
|
+
book_id VARCHAR
|
62
|
+
);
|
63
|
+
"""
|
64
|
+
),
|
49
65
|
]
|
50
66
|
|
51
67
|
|
@@ -1,9 +1,11 @@
|
|
1
1
|
from tbr_deal_finder.retailer.audible import Audible
|
2
2
|
from tbr_deal_finder.retailer.chirp import Chirp
|
3
|
+
from tbr_deal_finder.retailer.kindle import Kindle
|
3
4
|
from tbr_deal_finder.retailer.librofm import LibroFM
|
4
5
|
|
5
6
|
RETAILER_MAP = {
|
6
7
|
"Audible": Audible,
|
7
8
|
"Chirp": Chirp,
|
8
9
|
"Libro.FM": LibroFM,
|
10
|
+
"Kindle": Kindle,
|
9
11
|
}
|
@@ -0,0 +1,85 @@
|
|
1
|
+
import sys
|
2
|
+
import os.path
|
3
|
+
|
4
|
+
import audible
|
5
|
+
import click
|
6
|
+
from audible.login import build_init_cookies
|
7
|
+
from textwrap import dedent
|
8
|
+
|
9
|
+
if sys.platform != 'win32':
|
10
|
+
# Breaks Windows support but required for Mac
|
11
|
+
# Untested on Linux
|
12
|
+
import readline # type: ignore
|
13
|
+
|
14
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
15
|
+
from tbr_deal_finder.config import Config
|
16
|
+
from tbr_deal_finder.retailer.models import Retailer
|
17
|
+
|
18
|
+
_AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
|
19
|
+
|
20
|
+
|
21
|
+
def login_url_callback(url: str) -> str:
|
22
|
+
"""Helper function for login with external browsers."""
|
23
|
+
|
24
|
+
try:
|
25
|
+
from playwright.sync_api import sync_playwright # type: ignore
|
26
|
+
except ImportError:
|
27
|
+
pass
|
28
|
+
else:
|
29
|
+
with sync_playwright() as p:
|
30
|
+
iphone = p.devices["iPhone 12 Pro"]
|
31
|
+
browser = p.webkit.launch(headless=False)
|
32
|
+
context = browser.new_context(
|
33
|
+
**iphone
|
34
|
+
)
|
35
|
+
cookies = []
|
36
|
+
for name, value in build_init_cookies().items():
|
37
|
+
cookies.append(
|
38
|
+
{
|
39
|
+
"name": name,
|
40
|
+
"value": value,
|
41
|
+
"url": url
|
42
|
+
}
|
43
|
+
)
|
44
|
+
context.add_cookies(cookies)
|
45
|
+
page = browser.new_page()
|
46
|
+
page.goto(url)
|
47
|
+
|
48
|
+
while True:
|
49
|
+
page.wait_for_timeout(600)
|
50
|
+
if "/ap/maplanding" in page.url:
|
51
|
+
response_url = page.url
|
52
|
+
break
|
53
|
+
|
54
|
+
browser.close()
|
55
|
+
return response_url
|
56
|
+
|
57
|
+
message = f"""\
|
58
|
+
Please copy the following url and insert it into a web browser of your choice to log into Amazon.
|
59
|
+
Note: your browser will show you an error page (Page not found). This is expected.
|
60
|
+
|
61
|
+
{url}
|
62
|
+
|
63
|
+
Once you have logged in, please insert the copied url.
|
64
|
+
"""
|
65
|
+
click.echo(dedent(message))
|
66
|
+
return input()
|
67
|
+
|
68
|
+
|
69
|
+
class Amazon(Retailer):
|
70
|
+
_auth: audible.Authenticator = None
|
71
|
+
_client: audible.AsyncClient = None
|
72
|
+
|
73
|
+
async def set_auth(self):
|
74
|
+
if not os.path.exists(_AUTH_PATH):
|
75
|
+
auth = audible.Authenticator.from_login_external(
|
76
|
+
locale=Config.locale,
|
77
|
+
login_url_callback=login_url_callback
|
78
|
+
)
|
79
|
+
|
80
|
+
# Save credentials to file
|
81
|
+
auth.to_file(_AUTH_PATH)
|
82
|
+
|
83
|
+
self._auth = audible.Authenticator.from_file(_AUTH_PATH)
|
84
|
+
self._client = audible.AsyncClient(auth=self._auth)
|
85
|
+
|
@@ -1,74 +1,12 @@
|
|
1
1
|
import asyncio
|
2
2
|
import math
|
3
|
-
import os.path
|
4
|
-
from datetime import datetime
|
5
|
-
from textwrap import dedent
|
6
|
-
import readline # type: ignore
|
7
3
|
|
8
|
-
|
9
|
-
import audible
|
10
|
-
import click
|
11
|
-
from audible.login import build_init_cookies
|
12
|
-
|
13
|
-
from tbr_deal_finder import TBR_DEALS_PATH
|
14
4
|
from tbr_deal_finder.config import Config
|
15
|
-
from tbr_deal_finder.retailer.
|
16
|
-
from tbr_deal_finder.book import Book, BookFormat
|
17
|
-
|
18
|
-
_AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
|
19
|
-
|
20
|
-
|
21
|
-
def login_url_callback(url: str) -> str:
|
22
|
-
"""Helper function for login with external browsers."""
|
23
|
-
|
24
|
-
try:
|
25
|
-
from playwright.sync_api import sync_playwright # type: ignore
|
26
|
-
except ImportError:
|
27
|
-
pass
|
28
|
-
else:
|
29
|
-
with sync_playwright() as p:
|
30
|
-
iphone = p.devices["iPhone 12 Pro"]
|
31
|
-
browser = p.webkit.launch(headless=False)
|
32
|
-
context = browser.new_context(
|
33
|
-
**iphone
|
34
|
-
)
|
35
|
-
cookies = []
|
36
|
-
for name, value in build_init_cookies().items():
|
37
|
-
cookies.append(
|
38
|
-
{
|
39
|
-
"name": name,
|
40
|
-
"value": value,
|
41
|
-
"url": url
|
42
|
-
}
|
43
|
-
)
|
44
|
-
context.add_cookies(cookies)
|
45
|
-
page = browser.new_page()
|
46
|
-
page.goto(url)
|
47
|
-
|
48
|
-
while True:
|
49
|
-
page.wait_for_timeout(600)
|
50
|
-
if "/ap/maplanding" in page.url:
|
51
|
-
response_url = page.url
|
52
|
-
break
|
53
|
-
|
54
|
-
browser.close()
|
55
|
-
return response_url
|
56
|
-
|
57
|
-
message = f"""\
|
58
|
-
Please copy the following url and insert it into a web browser of your choice to log into Amazon.
|
59
|
-
Note: your browser will show you an error page (Page not found). This is expected.
|
60
|
-
|
61
|
-
{url}
|
5
|
+
from tbr_deal_finder.retailer.amazon import Amazon
|
6
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_title
|
62
7
|
|
63
|
-
Once you have logged in, please insert the copied url.
|
64
|
-
"""
|
65
|
-
click.echo(dedent(message))
|
66
|
-
return input()
|
67
8
|
|
68
|
-
|
69
|
-
class Audible(Retailer):
|
70
|
-
_auth: audible.Authenticator = None
|
71
|
-
_client: audible.AsyncClient = None
|
9
|
+
class Audible(Amazon):
|
72
10
|
|
73
11
|
@property
|
74
12
|
def name(self) -> str:
|
@@ -78,23 +16,9 @@ class Audible(Retailer):
|
|
78
16
|
def format(self) -> BookFormat:
|
79
17
|
return BookFormat.AUDIOBOOK
|
80
18
|
|
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
|
-
|
94
19
|
async def get_book(
|
95
20
|
self,
|
96
21
|
target: Book,
|
97
|
-
runtime: datetime,
|
98
22
|
semaphore: asyncio.Semaphore
|
99
23
|
) -> Book:
|
100
24
|
title = target.title
|
@@ -112,29 +36,18 @@ class Audible(Retailer):
|
|
112
36
|
)
|
113
37
|
|
114
38
|
for product in match.get("products", []):
|
115
|
-
if product["title"] != title:
|
39
|
+
if get_normalized_title(product["title"]) != title:
|
40
|
+
continue
|
41
|
+
try:
|
42
|
+
target.list_price = product["price"]["list_price"]["base"]
|
43
|
+
target.current_price = product["price"]["lowest_price"]["base"]
|
44
|
+
target.exists = True
|
45
|
+
return target
|
46
|
+
except KeyError:
|
116
47
|
continue
|
117
48
|
|
118
|
-
|
119
|
-
|
120
|
-
title=title,
|
121
|
-
authors=authors,
|
122
|
-
list_price=product["price"]["list_price"]["base"],
|
123
|
-
current_price=product["price"]["lowest_price"]["base"],
|
124
|
-
timepoint=runtime,
|
125
|
-
format=BookFormat.AUDIOBOOK
|
126
|
-
)
|
127
|
-
|
128
|
-
return Book(
|
129
|
-
retailer=self.name,
|
130
|
-
title=title,
|
131
|
-
authors=authors,
|
132
|
-
list_price=0,
|
133
|
-
current_price=0,
|
134
|
-
timepoint=runtime,
|
135
|
-
format=BookFormat.AUDIOBOOK,
|
136
|
-
exists=False,
|
137
|
-
)
|
49
|
+
target.exists = False
|
50
|
+
return target
|
138
51
|
|
139
52
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
140
53
|
wishlist_books = []
|