tbr-deal-finder 0.1.7__py3-none-any.whl → 0.2.0__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 +107 -56
- 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/__init__.py +2 -0
- tbr_deal_finder/retailer/amazon.py +85 -0
- tbr_deal_finder/retailer/audible.py +13 -100
- tbr_deal_finder/retailer/chirp.py +34 -55
- tbr_deal_finder/retailer/kindle.py +141 -0
- tbr_deal_finder/retailer/librofm.py +27 -53
- tbr_deal_finder/retailer/models.py +37 -2
- tbr_deal_finder/retailer_deal.py +9 -19
- tbr_deal_finder/tracked_books.py +258 -39
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/METADATA +5 -2
- tbr_deal_finder-0.2.0.dist-info/RECORD +24 -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.2.0.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/book.py
CHANGED
@@ -1,58 +1,58 @@
|
|
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
|
9
8
|
from unidecode import unidecode
|
10
9
|
|
11
10
|
from tbr_deal_finder.config import Config
|
12
|
-
from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name
|
11
|
+
from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name, echo_info
|
13
12
|
|
14
13
|
_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 = 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
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]:
|
@@ -114,13 +148,26 @@ def get_active_deals() -> list[Book]:
|
|
114
148
|
|
115
149
|
|
116
150
|
def print_books(books: list[Book]):
|
117
|
-
|
118
|
-
|
119
|
-
if prior_title_id != book.title_id:
|
120
|
-
prior_title_id = book.title_id
|
121
|
-
click.echo()
|
151
|
+
audiobooks = [book for book in books if book.format == BookFormat.AUDIOBOOK]
|
152
|
+
audiobooks = sorted(audiobooks, key=lambda book: book.deal_id)
|
122
153
|
|
123
|
-
|
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))
|
124
171
|
|
125
172
|
|
126
173
|
def get_full_title_str(title: str, authors: Union[list, str]) -> str:
|
@@ -131,6 +178,10 @@ def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat)
|
|
131
178
|
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
132
179
|
|
133
180
|
|
181
|
+
def get_normalized_title(title: str) -> str:
|
182
|
+
return title.split(":")[0].split("(")[0].strip()
|
183
|
+
|
184
|
+
|
134
185
|
def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
135
186
|
if isinstance(authors, str):
|
136
187
|
authors = [i for i in authors.split(",")]
|
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
|
|
@@ -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 = []
|
@@ -9,17 +9,19 @@ import click
|
|
9
9
|
|
10
10
|
from tbr_deal_finder import TBR_DEALS_PATH
|
11
11
|
from tbr_deal_finder.config import Config
|
12
|
-
from tbr_deal_finder.retailer.models import Retailer
|
12
|
+
from tbr_deal_finder.retailer.models import AioHttpSession, Retailer
|
13
13
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
14
14
|
from tbr_deal_finder.utils import currency_to_float, echo_err
|
15
15
|
|
16
16
|
|
17
|
-
class Chirp(Retailer):
|
17
|
+
class Chirp(AioHttpSession, Retailer):
|
18
18
|
# Static because url for other locales just redirects to .com
|
19
19
|
_url: str = "https://api.chirpbooks.com/api/graphql"
|
20
20
|
USER_AGENT = "ChirpBooks/5.13.9 (Android)"
|
21
21
|
|
22
22
|
def __init__(self):
|
23
|
+
super().__init__()
|
24
|
+
|
23
25
|
self.auth_token = None
|
24
26
|
|
25
27
|
@property
|
@@ -38,17 +40,17 @@ class Chirp(Retailer):
|
|
38
40
|
if self.auth_token:
|
39
41
|
headers["authorization"] = f"Bearer {self.auth_token}"
|
40
42
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
43
|
+
session = await self._get_session()
|
44
|
+
response = await session.request(
|
45
|
+
request_type.upper(),
|
46
|
+
self._url,
|
47
|
+
headers=headers,
|
48
|
+
**kwargs
|
49
|
+
)
|
50
|
+
if response.ok:
|
51
|
+
return await response.json()
|
52
|
+
else:
|
53
|
+
return {}
|
52
54
|
|
53
55
|
async def set_auth(self):
|
54
56
|
auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
|
@@ -84,35 +86,26 @@ class Chirp(Retailer):
|
|
84
86
|
json.dump(response, f)
|
85
87
|
|
86
88
|
async def get_book(
|
87
|
-
self, target: Book,
|
89
|
+
self, target: Book, semaphore: asyncio.Semaphore
|
88
90
|
) -> Book:
|
89
91
|
title = target.title
|
90
|
-
authors = target.authors
|
91
92
|
async with semaphore:
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
93
|
+
session = await self._get_session()
|
94
|
+
response = await session.request(
|
95
|
+
"POST",
|
96
|
+
self._url,
|
97
|
+
json={
|
98
|
+
"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}}}}}",
|
99
|
+
"variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
|
100
|
+
"operationName": "AudiobookSearch"
|
101
|
+
}
|
102
|
+
)
|
103
|
+
response_body = await response.json()
|
103
104
|
|
104
105
|
audiobooks = response_body["data"]["audiobooks"]["objects"]
|
105
106
|
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
|
-
)
|
107
|
+
target.exists = False
|
108
|
+
return target
|
116
109
|
|
117
110
|
for book in audiobooks:
|
118
111
|
if not book["currentProduct"]:
|
@@ -123,26 +116,12 @@ class Chirp(Retailer):
|
|
123
116
|
book["displayTitle"] == title
|
124
117
|
and is_matching_authors(target.normalized_authors, normalized_authors)
|
125
118
|
):
|
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
|
-
)
|
119
|
+
target.list_price = currency_to_float(book["currentProduct"]["listingPrice"])
|
120
|
+
target.current_price = currency_to_float(book["currentProduct"]["discountPrice"])
|
121
|
+
return target
|
135
122
|
|
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
|
-
)
|
123
|
+
target.exists = False
|
124
|
+
return target
|
146
125
|
|
147
126
|
async def get_wishlist(self, config: Config) -> list[Book]:
|
148
127
|
wishlist_books = []
|