tbr-deal-finder 0.1.7__py3-none-any.whl → 0.1.8__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/book.py +83 -49
- tbr_deal_finder/cli.py +5 -3
- tbr_deal_finder/config.py +10 -0
- tbr_deal_finder/migrations.py +16 -0
- tbr_deal_finder/queries/get_active_deals.sql +1 -1
- tbr_deal_finder/queries/get_deals_found_at.sql +1 -1
- tbr_deal_finder/retailer/amazon.py +79 -0
- tbr_deal_finder/retailer/audible.py +11 -97
- tbr_deal_finder/retailer/chirp.py +8 -31
- tbr_deal_finder/retailer/librofm.py +10 -37
- tbr_deal_finder/retailer/models.py +1 -2
- tbr_deal_finder/retailer_deal.py +9 -19
- tbr_deal_finder/tracked_books.py +239 -39
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.1.8.dist-info}/METADATA +2 -2
- tbr_deal_finder-0.1.8.dist-info/RECORD +23 -0
- tbr_deal_finder/library_exports.py +0 -208
- tbr_deal_finder-0.1.7.dist-info/RECORD +0 -23
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.1.8.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.1.8.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.1.8.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/book.py
CHANGED
@@ -1,8 +1,7 @@
|
|
1
|
-
import dataclasses
|
2
1
|
import re
|
3
2
|
from datetime import datetime
|
4
3
|
from enum import Enum
|
5
|
-
from typing import
|
4
|
+
from typing import Union
|
6
5
|
|
7
6
|
import click
|
8
7
|
from Levenshtein import ratio
|
@@ -15,44 +14,45 @@ _AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
|
|
15
14
|
|
16
15
|
class BookFormat(Enum):
|
17
16
|
AUDIOBOOK = "Audiobook"
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
class Book:
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
self.
|
45
|
-
self.
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
if isinstance(
|
55
|
-
|
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 = title.split(":")[0].split("(")[0].strip()
|
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
56
|
|
57
57
|
def discount(self) -> int:
|
58
58
|
return int((self.list_price/self.current_price - 1) * 100)
|
@@ -61,6 +61,10 @@ class Book:
|
|
61
61
|
def price_to_string(price: float) -> str:
|
62
62
|
return f"{Config.currency_symbol()}{price:.2f}"
|
63
63
|
|
64
|
+
@property
|
65
|
+
def deal_id(self) -> str:
|
66
|
+
return f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
67
|
+
|
64
68
|
@property
|
65
69
|
def title_id(self) -> str:
|
66
70
|
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
@@ -69,6 +73,22 @@ class Book:
|
|
69
73
|
def full_title_str(self) -> str:
|
70
74
|
return f"{self.title}__{self.normalized_authors}"
|
71
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
|
+
|
72
92
|
def list_price_string(self):
|
73
93
|
return self.price_to_string(self.list_price)
|
74
94
|
|
@@ -81,17 +101,31 @@ class Book:
|
|
81
101
|
title = self.title
|
82
102
|
if len(self.title) > 75:
|
83
103
|
title = f"{title[:75]}..."
|
84
|
-
return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer}
|
104
|
+
return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer}"
|
85
105
|
|
86
106
|
def dict(self):
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
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
|
+
}
|
95
129
|
|
96
130
|
|
97
131
|
def get_deals_found_at(timepoint: datetime) -> list[Book]:
|
tbr_deal_finder/cli.py
CHANGED
@@ -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,
|
tbr_deal_finder/config.py
CHANGED
@@ -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 = []
|
tbr_deal_finder/migrations.py
CHANGED
@@ -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
|
|
@@ -0,0 +1,79 @@
|
|
1
|
+
import os.path
|
2
|
+
|
3
|
+
import audible
|
4
|
+
import click
|
5
|
+
from audible.login import build_init_cookies
|
6
|
+
from textwrap import dedent
|
7
|
+
|
8
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
9
|
+
from tbr_deal_finder.config import Config
|
10
|
+
from tbr_deal_finder.retailer.models import Retailer
|
11
|
+
|
12
|
+
_AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
|
13
|
+
|
14
|
+
|
15
|
+
def login_url_callback(url: str) -> str:
|
16
|
+
"""Helper function for login with external browsers."""
|
17
|
+
|
18
|
+
try:
|
19
|
+
from playwright.sync_api import sync_playwright # type: ignore
|
20
|
+
except ImportError:
|
21
|
+
pass
|
22
|
+
else:
|
23
|
+
with sync_playwright() as p:
|
24
|
+
iphone = p.devices["iPhone 12 Pro"]
|
25
|
+
browser = p.webkit.launch(headless=False)
|
26
|
+
context = browser.new_context(
|
27
|
+
**iphone
|
28
|
+
)
|
29
|
+
cookies = []
|
30
|
+
for name, value in build_init_cookies().items():
|
31
|
+
cookies.append(
|
32
|
+
{
|
33
|
+
"name": name,
|
34
|
+
"value": value,
|
35
|
+
"url": url
|
36
|
+
}
|
37
|
+
)
|
38
|
+
context.add_cookies(cookies)
|
39
|
+
page = browser.new_page()
|
40
|
+
page.goto(url)
|
41
|
+
|
42
|
+
while True:
|
43
|
+
page.wait_for_timeout(600)
|
44
|
+
if "/ap/maplanding" in page.url:
|
45
|
+
response_url = page.url
|
46
|
+
break
|
47
|
+
|
48
|
+
browser.close()
|
49
|
+
return response_url
|
50
|
+
|
51
|
+
message = f"""\
|
52
|
+
Please copy the following url and insert it into a web browser of your choice to log into Amazon.
|
53
|
+
Note: your browser will show you an error page (Page not found). This is expected.
|
54
|
+
|
55
|
+
{url}
|
56
|
+
|
57
|
+
Once you have logged in, please insert the copied url.
|
58
|
+
"""
|
59
|
+
click.echo(dedent(message))
|
60
|
+
return input()
|
61
|
+
|
62
|
+
|
63
|
+
class Amazon(Retailer):
|
64
|
+
_auth: audible.Authenticator = None
|
65
|
+
_client: audible.AsyncClient = None
|
66
|
+
|
67
|
+
async def set_auth(self):
|
68
|
+
if not os.path.exists(_AUTH_PATH):
|
69
|
+
auth = audible.Authenticator.from_login_external(
|
70
|
+
locale=Config.locale,
|
71
|
+
login_url_callback=login_url_callback
|
72
|
+
)
|
73
|
+
|
74
|
+
# Save credentials to file
|
75
|
+
auth.to_file(_AUTH_PATH)
|
76
|
+
|
77
|
+
self._auth = audible.Authenticator.from_file(_AUTH_PATH)
|
78
|
+
self._client = audible.AsyncClient(auth=self._auth)
|
79
|
+
|
@@ -1,74 +1,13 @@
|
|
1
1
|
import asyncio
|
2
2
|
import math
|
3
|
-
import os.path
|
4
|
-
from datetime import datetime
|
5
|
-
from textwrap import dedent
|
6
3
|
import readline # type: ignore
|
7
4
|
|
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
5
|
from tbr_deal_finder.config import Config
|
15
|
-
from tbr_deal_finder.retailer.
|
6
|
+
from tbr_deal_finder.retailer.amazon import Amazon
|
16
7
|
from tbr_deal_finder.book import Book, BookFormat
|
17
8
|
|
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
9
|
|
68
|
-
|
69
|
-
class Audible(Retailer):
|
70
|
-
_auth: audible.Authenticator = None
|
71
|
-
_client: audible.AsyncClient = None
|
10
|
+
class Audible(Amazon):
|
72
11
|
|
73
12
|
@property
|
74
13
|
def name(self) -> str:
|
@@ -78,23 +17,9 @@ class Audible(Retailer):
|
|
78
17
|
def format(self) -> BookFormat:
|
79
18
|
return BookFormat.AUDIOBOOK
|
80
19
|
|
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
20
|
async def get_book(
|
95
21
|
self,
|
96
22
|
target: Book,
|
97
|
-
runtime: datetime,
|
98
23
|
semaphore: asyncio.Semaphore
|
99
24
|
) -> Book:
|
100
25
|
title = target.title
|
@@ -114,27 +39,16 @@ class Audible(Retailer):
|
|
114
39
|
for product in match.get("products", []):
|
115
40
|
if product["title"] != title:
|
116
41
|
continue
|
42
|
+
try:
|
43
|
+
target.list_price = product["price"]["list_price"]["base"]
|
44
|
+
target.current_price = product["price"]["lowest_price"]["base"]
|
45
|
+
target.exists = True
|
46
|
+
return target
|
47
|
+
except KeyError:
|
48
|
+
continue
|
117
49
|
|
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
|
-
)
|
50
|
+
target.exists = False
|
51
|
+
return target
|
138
52
|
|
139
53
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
140
54
|
wishlist_books = []
|
@@ -84,10 +84,9 @@ class Chirp(Retailer):
|
|
84
84
|
json.dump(response, f)
|
85
85
|
|
86
86
|
async def get_book(
|
87
|
-
self, target: Book,
|
87
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
88
88
|
) -> Book:
|
89
89
|
title = target.title
|
90
|
-
authors = target.authors
|
91
90
|
async with semaphore:
|
92
91
|
async with aiohttp.ClientSession() as http_client:
|
93
92
|
response = await http_client.request(
|
@@ -103,16 +102,8 @@ class Chirp(Retailer):
|
|
103
102
|
|
104
103
|
audiobooks = response_body["data"]["audiobooks"]["objects"]
|
105
104
|
if not audiobooks:
|
106
|
-
|
107
|
-
|
108
|
-
title=title,
|
109
|
-
authors=authors,
|
110
|
-
list_price=0,
|
111
|
-
current_price=0,
|
112
|
-
timepoint=runtime,
|
113
|
-
format=BookFormat.AUDIOBOOK,
|
114
|
-
exists=False,
|
115
|
-
)
|
105
|
+
target.exists = False
|
106
|
+
return target
|
116
107
|
|
117
108
|
for book in audiobooks:
|
118
109
|
if not book["currentProduct"]:
|
@@ -123,26 +114,12 @@ class Chirp(Retailer):
|
|
123
114
|
book["displayTitle"] == title
|
124
115
|
and is_matching_authors(target.normalized_authors, normalized_authors)
|
125
116
|
):
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
authors=authors,
|
130
|
-
list_price=currency_to_float(book["currentProduct"]["listingPrice"]),
|
131
|
-
current_price=currency_to_float(book["currentProduct"]["discountPrice"]),
|
132
|
-
timepoint=runtime,
|
133
|
-
format=BookFormat.AUDIOBOOK,
|
134
|
-
)
|
117
|
+
target.list_price = currency_to_float(book["currentProduct"]["listingPrice"])
|
118
|
+
target.current_price = currency_to_float(book["currentProduct"]["discountPrice"])
|
119
|
+
return target
|
135
120
|
|
136
|
-
|
137
|
-
|
138
|
-
title=title,
|
139
|
-
authors=target.authors,
|
140
|
-
list_price=0,
|
141
|
-
current_price=0,
|
142
|
-
timepoint=runtime,
|
143
|
-
format=BookFormat.AUDIOBOOK,
|
144
|
-
exists=False,
|
145
|
-
)
|
121
|
+
target.exists = False
|
122
|
+
return target
|
146
123
|
|
147
124
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
148
125
|
wishlist_books = []
|
@@ -76,11 +76,11 @@ class LibroFM(Retailer):
|
|
76
76
|
"password": click.prompt("Libro FM Password", hide_input=True),
|
77
77
|
}
|
78
78
|
)
|
79
|
-
self.auth_token = response
|
79
|
+
self.auth_token = response["access_token"]
|
80
80
|
with open(auth_path, "w") as f:
|
81
81
|
json.dump(response, f)
|
82
82
|
|
83
|
-
async def get_book_isbn(self, book: Book,
|
83
|
+
async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Book:
|
84
84
|
# runtime isn't used but get_book_isbn must follow the get_book method signature.
|
85
85
|
|
86
86
|
title = book.title
|
@@ -109,24 +109,11 @@ class LibroFM(Retailer):
|
|
109
109
|
return book
|
110
110
|
|
111
111
|
async def get_book(
|
112
|
-
self, target: Book,
|
112
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
113
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
|
-
|
119
114
|
if not target.audiobook_isbn:
|
120
|
-
|
121
|
-
|
122
|
-
title=target.title,
|
123
|
-
authors=target.authors,
|
124
|
-
list_price=0,
|
125
|
-
current_price=0,
|
126
|
-
timepoint=runtime,
|
127
|
-
format=BookFormat.AUDIOBOOK,
|
128
|
-
exists=False,
|
129
|
-
)
|
115
|
+
target.exists = False
|
116
|
+
return target
|
130
117
|
|
131
118
|
async with semaphore:
|
132
119
|
response = await self.make_request(
|
@@ -135,26 +122,12 @@ class LibroFM(Retailer):
|
|
135
122
|
)
|
136
123
|
|
137
124
|
if response:
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
authors=target.authors,
|
142
|
-
list_price=target.audiobook_list_price,
|
143
|
-
current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
|
144
|
-
timepoint=runtime,
|
145
|
-
format=BookFormat.AUDIOBOOK,
|
146
|
-
)
|
125
|
+
target.list_price = target.audiobook_list_price
|
126
|
+
target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
|
127
|
+
return target
|
147
128
|
|
148
|
-
|
149
|
-
|
150
|
-
title=target.title,
|
151
|
-
authors=target.authors,
|
152
|
-
list_price=0,
|
153
|
-
current_price=0,
|
154
|
-
timepoint=runtime,
|
155
|
-
format=BookFormat.AUDIOBOOK,
|
156
|
-
exists=False,
|
157
|
-
)
|
129
|
+
target.exists = False
|
130
|
+
return target
|
158
131
|
|
159
132
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
160
133
|
wishlist_books = []
|
@@ -1,6 +1,5 @@
|
|
1
1
|
import abc
|
2
2
|
import asyncio
|
3
|
-
from datetime import datetime
|
4
3
|
|
5
4
|
from tbr_deal_finder.book import Book, BookFormat
|
6
5
|
from tbr_deal_finder.config import Config
|
@@ -29,7 +28,7 @@ class Retailer(abc.ABC):
|
|
29
28
|
raise NotImplementedError
|
30
29
|
|
31
30
|
async def get_book(
|
32
|
-
self, target: Book,
|
31
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
33
32
|
) -> Book:
|
34
33
|
"""Get book information from the retailer.
|
35
34
|
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -8,7 +8,6 @@ from tqdm.asyncio import tqdm_asyncio
|
|
8
8
|
|
9
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.owned_books import get_owned_books
|
12
11
|
from tbr_deal_finder.tracked_books import get_tbr_books
|
13
12
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
14
13
|
from tbr_deal_finder.retailer.models import Retailer
|
@@ -22,9 +21,9 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
22
21
|
:param new_deals:
|
23
22
|
"""
|
24
23
|
|
25
|
-
# This could be done using a temp table for the new deals, but that feels like overkill
|
26
|
-
# I can't imagine there's ever going to be more than 5,000 books in someone's TBR
|
27
|
-
# If it were any larger we'd have bigger problems.
|
24
|
+
# This could be done using a temp table for the new deals, but that feels like overkill.
|
25
|
+
# I can't imagine there's ever going to be more than 5,000 books in someone's TBR.
|
26
|
+
# If it were any larger, we'd have bigger problems.
|
28
27
|
active_deal_map = {deal.deal_id: deal for deal in get_active_deals()}
|
29
28
|
# Dirty trick to ensure uniqueness in request
|
30
29
|
new_deals = list({nd.deal_id: nd for nd in new_deals}.values())
|
@@ -74,9 +73,13 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
74
73
|
semaphore = asyncio.Semaphore(10)
|
75
74
|
response = []
|
76
75
|
unresolved_books = []
|
76
|
+
books = [copy.deepcopy(book) for book in books]
|
77
|
+
for book in books:
|
78
|
+
book.retailer = retailer.name
|
79
|
+
book.format = retailer.format
|
77
80
|
|
78
81
|
tasks = [
|
79
|
-
retailer.get_book(
|
82
|
+
retailer.get_book(book, semaphore)
|
80
83
|
for book in books
|
81
84
|
]
|
82
85
|
results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
|
@@ -130,26 +133,19 @@ def _apply_proper_list_prices(books: list[Book]):
|
|
130
133
|
def _get_retailer_relevant_tbr_books(
|
131
134
|
retailer: Retailer,
|
132
135
|
books: list[Book],
|
133
|
-
owned_book_title_map: dict[str, dict[BookFormat, Book]],
|
134
136
|
) -> list[Book]:
|
135
137
|
"""
|
136
138
|
Don't check on deals in a specified format that does not match the format the retailer sells.
|
137
|
-
Also, don't check on deals for a book if a copy is already owned in that same format.
|
138
139
|
|
139
140
|
:param retailer:
|
140
141
|
:param books:
|
141
|
-
:param owned_book_title_map:
|
142
142
|
:return:
|
143
143
|
"""
|
144
144
|
|
145
145
|
response = []
|
146
146
|
|
147
147
|
for book in books:
|
148
|
-
|
149
|
-
if (
|
150
|
-
(book.format == BookFormat.NA or book.format == retailer.format)
|
151
|
-
and retailer.format not in owned_versions
|
152
|
-
):
|
148
|
+
if book.format == BookFormat.NA or book.format == retailer.format:
|
153
149
|
response.append(book)
|
154
150
|
|
155
151
|
return response
|
@@ -174,11 +170,6 @@ async def get_latest_deals(config: Config):
|
|
174
170
|
|
175
171
|
books: list[Book] = []
|
176
172
|
tbr_books = await get_tbr_books(config)
|
177
|
-
owned_books = await get_owned_books(config)
|
178
|
-
|
179
|
-
owned_book_title_map: dict[str, dict[BookFormat, Book]] = defaultdict(dict)
|
180
|
-
for book in owned_books:
|
181
|
-
owned_book_title_map[book.full_title_str][book.format] = book
|
182
173
|
|
183
174
|
for retailer_str in config.tracked_retailers:
|
184
175
|
retailer = RETAILER_MAP[retailer_str]()
|
@@ -187,7 +178,6 @@ async def get_latest_deals(config: Config):
|
|
187
178
|
relevant_tbr_books = _get_retailer_relevant_tbr_books(
|
188
179
|
retailer,
|
189
180
|
tbr_books,
|
190
|
-
owned_book_title_map,
|
191
181
|
)
|
192
182
|
|
193
183
|
echo_info(f"Getting deals from {retailer.name}")
|
tbr_deal_finder/tracked_books.py
CHANGED
@@ -1,19 +1,18 @@
|
|
1
1
|
import asyncio
|
2
|
+
import copy
|
2
3
|
import csv
|
4
|
+
from collections import defaultdict
|
5
|
+
from typing import Callable, Awaitable, Optional
|
3
6
|
|
7
|
+
import pandas as pd
|
4
8
|
from tqdm.asyncio import tqdm_asyncio
|
5
9
|
|
6
10
|
from tbr_deal_finder.book import Book, BookFormat, get_title_id
|
7
|
-
from tbr_deal_finder.
|
11
|
+
from tbr_deal_finder.owned_books import get_owned_books
|
12
|
+
from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM
|
8
13
|
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
14
|
from tbr_deal_finder.retailer.models import Retailer
|
16
|
-
from tbr_deal_finder.utils import
|
15
|
+
from tbr_deal_finder.utils import execute_query, get_duckdb_conn
|
17
16
|
|
18
17
|
|
19
18
|
def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
@@ -35,7 +34,7 @@ def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
|
35
34
|
authors = get_book_authors(book_dict)
|
36
35
|
|
37
36
|
key = get_title_id(title, authors, BookFormat.NA)
|
38
|
-
if key in tbr_book_map
|
37
|
+
if key in tbr_book_map:
|
39
38
|
continue
|
40
39
|
|
41
40
|
tbr_book_map[key] = Book(
|
@@ -46,33 +45,9 @@ def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
|
46
45
|
current_price=0,
|
47
46
|
timepoint=config.run_time,
|
48
47
|
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
48
|
)
|
52
49
|
|
53
50
|
|
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
51
|
async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
|
77
52
|
"""Adds wishlist books in the library export to the provided tbr_book_map
|
78
53
|
Books added here has the format the retailer sells (e.g. Audiobook)
|
@@ -102,19 +77,244 @@ async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
|
|
102
77
|
|
103
78
|
tbr_book_map[key] = book
|
104
79
|
|
105
|
-
if requires_audiobook_list_price_default(config):
|
106
|
-
await _apply_dynamic_audiobook_list_price(config, tbr_book_map)
|
107
80
|
|
81
|
+
async def _get_raw_tbr_books(config: Config) -> list[Book]:
|
82
|
+
"""Gets books in any library export or tracked retailer wishlist
|
108
83
|
|
109
|
-
|
110
|
-
|
84
|
+
Excludes books in the format they are owned.
|
85
|
+
Example: User owns Dungeon Crawler Carl Audiobook but it's on the user TBR.
|
86
|
+
Result - Deals will be tracked for the Dungeon Crawler Carl EBook (if tracking kindle deals)
|
87
|
+
|
88
|
+
:param config:
|
89
|
+
:return:
|
90
|
+
"""
|
91
|
+
|
92
|
+
owned_books = await get_owned_books(config)
|
93
|
+
tracking_audiobooks = config.is_tracking_format(book_format=BookFormat.AUDIOBOOK)
|
94
|
+
tracking_ebooks = config.is_tracking_format(book_format=BookFormat.EBOOK)
|
111
95
|
|
96
|
+
tbr_book_map: dict[str: Book] = {}
|
112
97
|
# Get TBRs specified in the user library (StoryGraph/GoodReads) export
|
113
98
|
_library_export_tbr_books(config, tbr_book_map)
|
114
|
-
|
115
99
|
# Pull wishlist from tracked retailers
|
116
100
|
await _retailer_wishlist(config, tbr_book_map)
|
101
|
+
raw_tbr_books = list(tbr_book_map.values())
|
102
|
+
|
103
|
+
response: list[Book] = []
|
104
|
+
|
105
|
+
owned_book_title_map: dict[str, set] = defaultdict(set)
|
106
|
+
for book in owned_books:
|
107
|
+
owned_book_title_map[book.full_title_str].add(book.format)
|
108
|
+
|
109
|
+
for book in raw_tbr_books:
|
110
|
+
owned_formats = owned_book_title_map.get(book.full_title_str)
|
111
|
+
if not owned_formats:
|
112
|
+
response.append(book)
|
113
|
+
elif BookFormat.NA in owned_formats:
|
114
|
+
continue
|
115
|
+
elif tracking_audiobooks and BookFormat.AUDIOBOOK not in owned_formats:
|
116
|
+
book.format = BookFormat.AUDIOBOOK
|
117
|
+
response.append(book)
|
118
|
+
elif tracking_ebooks and BookFormat.EBOOK not in owned_formats:
|
119
|
+
book.format = BookFormat.EBOOK
|
120
|
+
response.append(book)
|
121
|
+
|
122
|
+
return response
|
123
|
+
|
124
|
+
|
125
|
+
async def _set_tbr_book_attr(
|
126
|
+
tbr_books: list[Book],
|
127
|
+
target_attr: str,
|
128
|
+
get_book_callable: Callable[[Book, asyncio.Semaphore], Awaitable[Book]],
|
129
|
+
tbr_book_attr: Optional[str] = None
|
130
|
+
):
|
131
|
+
if not tbr_books:
|
132
|
+
return
|
133
|
+
|
134
|
+
if not tbr_book_attr:
|
135
|
+
tbr_book_attr = target_attr
|
136
|
+
|
137
|
+
tbr_books_map = {b.full_title_str: b for b in tbr_books}
|
138
|
+
tbr_books_copy = copy.deepcopy(tbr_books)
|
139
|
+
semaphore = asyncio.Semaphore(5)
|
140
|
+
human_readable_name = target_attr.replace("_", " ").title()
|
141
|
+
|
142
|
+
# Get books with the appropriate transform applied
|
143
|
+
# Responsibility is on the callable here
|
144
|
+
enriched_books = await tqdm_asyncio.gather(
|
145
|
+
*[
|
146
|
+
get_book_callable(book, semaphore) for book in tbr_books_copy
|
147
|
+
],
|
148
|
+
desc=f"Getting required {human_readable_name} info"
|
149
|
+
)
|
150
|
+
for enriched_book in enriched_books:
|
151
|
+
book = tbr_books_map[enriched_book.full_title_str]
|
152
|
+
setattr(
|
153
|
+
book,
|
154
|
+
tbr_book_attr,
|
155
|
+
getattr(enriched_book, target_attr)
|
156
|
+
)
|
157
|
+
|
158
|
+
|
159
|
+
def _requires_audiobook_list_price(config: Config):
|
160
|
+
return bool(
|
161
|
+
"Libro.FM" in config.tracked_retailers
|
162
|
+
and "Audible" not in config.tracked_retailers
|
163
|
+
and "Chirp" not in config.tracked_retailers
|
164
|
+
)
|
165
|
+
|
166
|
+
|
167
|
+
async def _maybe_set_audiobook_list_price(config: Config, new_tbr_books: list[Book]):
|
168
|
+
"""Set a default list price for audiobooks
|
169
|
+
|
170
|
+
Only set if not currently set and the only audiobook retailer is Libro.FM
|
171
|
+
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
172
|
+
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
173
|
+
|
174
|
+
:param config:
|
175
|
+
:return:
|
176
|
+
"""
|
177
|
+
if not _requires_audiobook_list_price(config):
|
178
|
+
return
|
179
|
+
|
180
|
+
chirp = Chirp()
|
181
|
+
relevant_tbr_books = [
|
182
|
+
book
|
183
|
+
for book in new_tbr_books
|
184
|
+
if book.format in [BookFormat.AUDIOBOOK, BookFormat.NA]
|
185
|
+
]
|
186
|
+
|
187
|
+
await _set_tbr_book_attr(
|
188
|
+
relevant_tbr_books,
|
189
|
+
"list_price",
|
190
|
+
chirp.get_book,
|
191
|
+
"audiobook_list_price"
|
192
|
+
)
|
193
|
+
|
194
|
+
|
195
|
+
async def _maybe_set_audiobook_isbn(config: Config, new_tbr_books: list[Book]):
|
196
|
+
"""To get the price from Libro.fm for a book, you need its ISBN
|
197
|
+
|
198
|
+
As opposed to trying to get that every time latest-deals is run
|
199
|
+
we're just updating the export csv once to include the ISBN.
|
200
|
+
|
201
|
+
"""
|
202
|
+
if "Libro.FM" not in config.tracked_retailers:
|
203
|
+
return
|
204
|
+
|
205
|
+
libro_fm = LibroFM()
|
206
|
+
await libro_fm.set_auth()
|
207
|
+
|
208
|
+
relevant_tbr_books = [
|
209
|
+
book
|
210
|
+
for book in new_tbr_books
|
211
|
+
if book.format in [BookFormat.AUDIOBOOK, BookFormat.NA]
|
212
|
+
]
|
213
|
+
|
214
|
+
await _set_tbr_book_attr(
|
215
|
+
relevant_tbr_books,
|
216
|
+
"audiobook_isbn",
|
217
|
+
libro_fm.get_book_isbn,
|
218
|
+
)
|
219
|
+
|
220
|
+
|
221
|
+
def get_book_authors(book: dict) -> str:
|
222
|
+
if authors := book.get('Authors'):
|
223
|
+
return authors
|
224
|
+
|
225
|
+
authors = book['Author']
|
226
|
+
if additional_authors := book.get("Additional Authors"):
|
227
|
+
authors = f"{authors}, {additional_authors}"
|
228
|
+
|
229
|
+
return authors
|
230
|
+
|
231
|
+
|
232
|
+
def get_book_title(book: dict) -> str:
|
233
|
+
title = book['Title']
|
234
|
+
return title.split("(")[0].strip()
|
235
|
+
|
236
|
+
|
237
|
+
def is_tbr_book(book: dict) -> bool:
|
238
|
+
if "Read Status" in book:
|
239
|
+
return book["Read Status"] == "to-read"
|
240
|
+
elif "Bookshelves" in book:
|
241
|
+
return "to-read" in book["Bookshelves"]
|
242
|
+
else:
|
243
|
+
return True
|
117
244
|
|
118
|
-
return list(tbr_book_map.values())
|
119
245
|
|
246
|
+
def reprocess_incomplete_tbr_books(config: Config):
|
247
|
+
db_conn = get_duckdb_conn()
|
248
|
+
|
249
|
+
if config.is_tracking_format(BookFormat.EBOOK):
|
250
|
+
# Replace any tbr_books missing required attr
|
251
|
+
db_conn.execute(
|
252
|
+
"DELETE FROM tbr_book WHERE ebook_asin IS NULL AND format != $book_format",
|
253
|
+
parameters=dict(book_format=BookFormat.AUDIOBOOK.value)
|
254
|
+
)
|
255
|
+
|
256
|
+
if LibroFM().name in config.tracked_retailers:
|
257
|
+
# Replace any tbr_books missing required attr
|
258
|
+
db_conn.execute(
|
259
|
+
"DELETE FROM tbr_book WHERE audiobook_isbn IS NULL AND format != $book_format",
|
260
|
+
parameters=dict(book_format=BookFormat.EBOOK.value)
|
261
|
+
)
|
262
|
+
|
263
|
+
if _requires_audiobook_list_price(config):
|
264
|
+
# Replace any tbr_books missing required attr
|
265
|
+
db_conn.execute(
|
266
|
+
"DELETE FROM tbr_book WHERE audiobook_list_price IS NULL AND format != $book_format",
|
267
|
+
parameters=dict(book_format=BookFormat.EBOOK.value)
|
268
|
+
)
|
269
|
+
|
270
|
+
|
271
|
+
async def sync_tbr_books(config: Config):
|
272
|
+
raw_tbr_books = await _get_raw_tbr_books(config)
|
273
|
+
db_conn = get_duckdb_conn()
|
274
|
+
|
275
|
+
if not raw_tbr_books:
|
276
|
+
return
|
277
|
+
|
278
|
+
df = pd.DataFrame([book.tbr_dict() for book in raw_tbr_books])
|
279
|
+
db_conn.register("_df", df)
|
280
|
+
db_conn.execute("CREATE OR REPLACE TABLE _latest_tbr_book AS SELECT * FROM _df;")
|
281
|
+
db_conn.unregister("_df")
|
282
|
+
|
283
|
+
# Remove books no longer on user tbr
|
284
|
+
db_conn.execute(
|
285
|
+
"DELETE FROM tbr_book WHERE book_id NOT IN (SELECT book_id FROM _latest_tbr_book)"
|
286
|
+
)
|
287
|
+
|
288
|
+
# Remove books from _latest_tbr_book for further processing for books already in tbr_book
|
289
|
+
db_conn.execute(
|
290
|
+
"DELETE FROM _latest_tbr_book WHERE book_id IN (SELECT book_id FROM tbr_book)"
|
291
|
+
)
|
292
|
+
|
293
|
+
new_tbr_book_data = execute_query(
|
294
|
+
db_conn,
|
295
|
+
"SELECT * EXCLUDE(book_id) FROM _latest_tbr_book"
|
296
|
+
)
|
297
|
+
|
298
|
+
new_tbr_books = [Book(retailer="N/A", timepoint=config.run_time, **b) for b in new_tbr_book_data]
|
299
|
+
if not new_tbr_books:
|
300
|
+
return
|
301
|
+
|
302
|
+
await _maybe_set_audiobook_list_price(config, new_tbr_books)
|
303
|
+
await _maybe_set_audiobook_isbn(config, new_tbr_books)
|
304
|
+
|
305
|
+
df = pd.DataFrame([book.tbr_dict() for book in new_tbr_books])
|
306
|
+
db_conn.register("_df", df)
|
307
|
+
db_conn.execute("INSERT INTO tbr_book SELECT * FROM _df;")
|
308
|
+
db_conn.unregister("_df")
|
309
|
+
|
310
|
+
|
311
|
+
async def get_tbr_books(config: Config) -> list[Book]:
|
312
|
+
await sync_tbr_books(config)
|
313
|
+
|
314
|
+
db_conn = get_duckdb_conn()
|
315
|
+
tbr_book_data = execute_query(
|
316
|
+
db_conn,
|
317
|
+
"SELECT * EXCLUDE(book_id) FROM tbr_book"
|
318
|
+
)
|
120
319
|
|
320
|
+
return [Book(retailer="N/A", timepoint=config.run_time, **b) for b in tbr_book_data]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.8
|
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
|
@@ -56,7 +56,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
56
56
|
1. If it's not already on your computer, download Python https://www.python.org/downloads/
|
57
57
|
1. tbr-deal-finder requires Python3.13 or higher
|
58
58
|
2. Optional: Install and use virtualenv
|
59
|
-
3. Open your Terminal/
|
59
|
+
3. Open your Terminal/Command Prompt
|
60
60
|
4. Run `pip3.13 install tbr-deal-finder`
|
61
61
|
|
62
62
|
### UV
|
@@ -0,0 +1,23 @@
|
|
1
|
+
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
+
tbr_deal_finder/book.py,sha256=MYdZ7WeSo9hWy9Af7T5U3-7zPgi5qdMPqlNtRYPMLck,5492
|
3
|
+
tbr_deal_finder/cli.py,sha256=C4F2rbPrfYNqlmolx08ZHDCcFJuiPbkc4ECXUO25kmI,7446
|
4
|
+
tbr_deal_finder/config.py,sha256=-TtZLv4kVBf56xPkgAdKXeVRV0qw8MZ53XHBQ1HnVX8,3978
|
5
|
+
tbr_deal_finder/migrations.py,sha256=_ZxUXzGyEFYlPlpzMvViDVPZJc5BNOiixj150U8HRFc,4224
|
6
|
+
tbr_deal_finder/owned_books.py,sha256=Cf1VeiSg7XBi_TXptJfy5sO1mEgMMQWbJ_P6SzAx0nQ,516
|
7
|
+
tbr_deal_finder/retailer_deal.py,sha256=jv32WSOtxVxCkxTCLkOqSkcHGHhWfbD4nSxY42Cqk38,6422
|
8
|
+
tbr_deal_finder/tracked_books.py,sha256=SOmygViADjr8fa3RiB4dn_bWjIpiAOBNQK37MHOu7nE,10407
|
9
|
+
tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
|
10
|
+
tbr_deal_finder/queries/get_active_deals.sql,sha256=nh0F1lRV6YVrUV7gsQpjsgfXmN9R0peBeMHRifjgpUM,212
|
11
|
+
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=KqrtQk7FS4Hf74RyL1r-oD2D-RJz1urrxKxkwlvjAro,139
|
12
|
+
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
13
|
+
tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
|
14
|
+
tbr_deal_finder/retailer/amazon.py,sha256=-U28l3LmVCbOvK0SIXG6MAfYgoRUA--enNQJBxA_NHM,2322
|
15
|
+
tbr_deal_finder/retailer/audible.py,sha256=1zM95teGth4UPyK5ZogLM9SIQxVd3kTgjTEiglnQKR8,3979
|
16
|
+
tbr_deal_finder/retailer/chirp.py,sha256=s5aj3NwMA1GSymmTylnoWghQsqMp7hFcMdnoA1TRsv4,9241
|
17
|
+
tbr_deal_finder/retailer/librofm.py,sha256=ARkUlxMS3OCgfKosX_XKVmMfLcIIDRPzqqmVp0V9IZA,6327
|
18
|
+
tbr_deal_finder/retailer/models.py,sha256=I_U8VC5AOozGfPecW7FW8eRoTWEOxnMIHPNbuOJWcjw,1463
|
19
|
+
tbr_deal_finder-0.1.8.dist-info/METADATA,sha256=4urAR2x-ABDN0OMscyowYP8Gqc_RXlVsYJoaRxpF7m8,4342
|
20
|
+
tbr_deal_finder-0.1.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
21
|
+
tbr_deal_finder-0.1.8.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
22
|
+
tbr_deal_finder-0.1.8.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
23
|
+
tbr_deal_finder-0.1.8.dist-info/RECORD,,
|
@@ -1,208 +0,0 @@
|
|
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_full_title_str
|
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 config.library_export_paths:
|
65
|
-
return
|
66
|
-
|
67
|
-
if not column_name:
|
68
|
-
column_name = attr_name
|
69
|
-
|
70
|
-
books_requiring_check_map = dict()
|
71
|
-
book_to_col_val_map = dict()
|
72
|
-
|
73
|
-
# Iterate all library export paths
|
74
|
-
for library_export_path in config.library_export_paths:
|
75
|
-
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
76
|
-
# Use csv.DictReader to get dictionaries with column headers
|
77
|
-
for book_dict in csv.DictReader(file):
|
78
|
-
if not is_tbr_book(book_dict):
|
79
|
-
continue
|
80
|
-
|
81
|
-
title = get_book_title(book_dict)
|
82
|
-
authors = get_book_authors(book_dict)
|
83
|
-
key = get_full_title_str(title, authors)
|
84
|
-
|
85
|
-
if column_name in book_dict:
|
86
|
-
# Keep state of value for this book/key
|
87
|
-
# in the event another export has the same book but the value is not set
|
88
|
-
book_to_col_val_map[key] = book_dict[column_name]
|
89
|
-
# Value has been found so a check no longer needs to be performed
|
90
|
-
books_requiring_check_map.pop(key, None)
|
91
|
-
elif key not in book_to_col_val_map:
|
92
|
-
# Not found, add the book to those requiring the column val to be set
|
93
|
-
books_requiring_check_map[key] = Book(
|
94
|
-
retailer="N/A",
|
95
|
-
title=title,
|
96
|
-
authors=authors,
|
97
|
-
list_price=0,
|
98
|
-
current_price=0,
|
99
|
-
timepoint=config.run_time,
|
100
|
-
format=BookFormat.NA
|
101
|
-
)
|
102
|
-
|
103
|
-
if not books_requiring_check_map:
|
104
|
-
# Everything was resolved, nothing else to do
|
105
|
-
return
|
106
|
-
|
107
|
-
semaphore = asyncio.Semaphore(5)
|
108
|
-
human_readable_name = attr_name.replace("_", " ").title()
|
109
|
-
|
110
|
-
# Get books with the appropriate transform applied
|
111
|
-
# Responsibility is on the callable here
|
112
|
-
enriched_books = await tqdm_asyncio.gather(
|
113
|
-
*[
|
114
|
-
get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
|
115
|
-
],
|
116
|
-
desc=f"Getting required {human_readable_name} info"
|
117
|
-
)
|
118
|
-
updated_book_map = {
|
119
|
-
b.full_title_str: b
|
120
|
-
for b in enriched_books
|
121
|
-
}
|
122
|
-
|
123
|
-
|
124
|
-
# Go back and now add the new column where it hasn't been set
|
125
|
-
for library_export_path in config.library_export_paths:
|
126
|
-
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
127
|
-
reader = csv.DictReader(file)
|
128
|
-
field_names = list(reader.fieldnames) + [column_name]
|
129
|
-
file_content = [book_dict for book_dict in reader]
|
130
|
-
if not file_content or column_name in file_content[0]:
|
131
|
-
continue
|
132
|
-
|
133
|
-
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
|
134
|
-
temp_filename = temp_file.name
|
135
|
-
writer = csv.DictWriter(temp_file, fieldnames=field_names)
|
136
|
-
writer.writeheader()
|
137
|
-
|
138
|
-
for book_dict in file_content:
|
139
|
-
if is_tbr_book(book_dict):
|
140
|
-
title = get_book_title(book_dict)
|
141
|
-
authors = get_book_authors(book_dict)
|
142
|
-
key = get_full_title_str(title, authors)
|
143
|
-
|
144
|
-
if key in book_to_col_val_map:
|
145
|
-
col_val = book_to_col_val_map[key]
|
146
|
-
elif key in updated_book_map:
|
147
|
-
book = updated_book_map[key]
|
148
|
-
col_val = getattr(book, attr_name)
|
149
|
-
else:
|
150
|
-
col_val = ""
|
151
|
-
|
152
|
-
book_dict[column_name] = col_val
|
153
|
-
else:
|
154
|
-
book_dict[column_name] = ""
|
155
|
-
|
156
|
-
writer.writerow(book_dict)
|
157
|
-
|
158
|
-
shutil.move(temp_filename, library_export_path)
|
159
|
-
|
160
|
-
|
161
|
-
async def _maybe_set_library_export_audiobook_isbn(config: Config):
|
162
|
-
"""To get the price from Libro.fm for a book, you need its ISBN
|
163
|
-
|
164
|
-
As opposed to trying to get that every time latest-deals is run
|
165
|
-
we're just updating the export csv once to include the ISBN.
|
166
|
-
|
167
|
-
Unfortunately, we do have to get it at run time for wishlists.
|
168
|
-
"""
|
169
|
-
if "Libro.FM" not in config.tracked_retailers:
|
170
|
-
return
|
171
|
-
|
172
|
-
libro_fm = LibroFM()
|
173
|
-
await libro_fm.set_auth()
|
174
|
-
|
175
|
-
await _maybe_set_column_for_library_exports(
|
176
|
-
config,
|
177
|
-
"audiobook_isbn",
|
178
|
-
libro_fm.get_book_isbn,
|
179
|
-
)
|
180
|
-
|
181
|
-
|
182
|
-
async def _maybe_set_library_export_audiobook_list_price(config: Config):
|
183
|
-
"""Set a default list price for audiobooks
|
184
|
-
|
185
|
-
Only set if not currently set and the only audiobook retailer is Libro.FM
|
186
|
-
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
187
|
-
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
188
|
-
|
189
|
-
:param config:
|
190
|
-
:return:
|
191
|
-
"""
|
192
|
-
if not requires_audiobook_list_price_default(config):
|
193
|
-
return
|
194
|
-
|
195
|
-
chirp = Chirp()
|
196
|
-
await chirp.set_auth()
|
197
|
-
|
198
|
-
await _maybe_set_column_for_library_exports(
|
199
|
-
config,
|
200
|
-
"list_price",
|
201
|
-
chirp.get_book,
|
202
|
-
"audiobook_list_price"
|
203
|
-
)
|
204
|
-
|
205
|
-
|
206
|
-
async def maybe_enrich_library_exports(config: Config):
|
207
|
-
await _maybe_set_library_export_audiobook_isbn(config)
|
208
|
-
await _maybe_set_library_export_audiobook_list_price(config)
|
@@ -1,23 +0,0 @@
|
|
1
|
-
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
-
tbr_deal_finder/book.py,sha256=JUhhDAV_vajhSyaD5begFvX_HPwEiZfojQ2x57qrf5M,4563
|
3
|
-
tbr_deal_finder/cli.py,sha256=iwmbUxwqD6HtKppf2QlDMENFnYxTnPQWSK1WLUpyOW8,7357
|
4
|
-
tbr_deal_finder/config.py,sha256=I69JruWIlnwxNiUMyOFq3K5sMmtXJKQxLKBU98DM008,3662
|
5
|
-
tbr_deal_finder/library_exports.py,sha256=hs2_GE0HP78EQ8GL0Lmalq-ihSp88i1OL-3VLD0Djhk,7163
|
6
|
-
tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
|
7
|
-
tbr_deal_finder/owned_books.py,sha256=Cf1VeiSg7XBi_TXptJfy5sO1mEgMMQWbJ_P6SzAx0nQ,516
|
8
|
-
tbr_deal_finder/retailer_deal.py,sha256=UGVb8wxv98vEWy9wX6UM5ePhIa00xHPtzJCgngximHc,6949
|
9
|
-
tbr_deal_finder/tracked_books.py,sha256=EKecARSOMPPkmjSSWfWQw9B-TnoSd3-6AQepc2EYTz0,4031
|
10
|
-
tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
|
11
|
-
tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
|
12
|
-
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
|
13
|
-
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
14
|
-
tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
|
15
|
-
tbr_deal_finder/retailer/audible.py,sha256=kgDNobu7uYV5IwReTDL_e0J731fMdOiJBipsno7Zv0A,6561
|
16
|
-
tbr_deal_finder/retailer/chirp.py,sha256=8IaVVbUcY-bLV6cy4wKXhKmW2qJy6HksxxG1uLpqSeA,10063
|
17
|
-
tbr_deal_finder/retailer/librofm.py,sha256=wN51UrDVaHb4XwoO0MY1_7ivhAE_BxoBWDavd7MpRxE,7413
|
18
|
-
tbr_deal_finder/retailer/models.py,sha256=vomL99LDP_52r61W1CBE34yxAWteD_QE5NeqSATgnAE,1512
|
19
|
-
tbr_deal_finder-0.1.7.dist-info/METADATA,sha256=nYxkGPCh7SOIg6RRktVidC7YpPfoxaWT6jZJ0JmpR_E,4343
|
20
|
-
tbr_deal_finder-0.1.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
21
|
-
tbr_deal_finder-0.1.7.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
22
|
-
tbr_deal_finder-0.1.7.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
23
|
-
tbr_deal_finder-0.1.7.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|