tbr-deal-finder 0.1.5__py3-none-any.whl → 0.1.6__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 +18 -5
- tbr_deal_finder/cli.py +10 -5
- tbr_deal_finder/library_exports.py +121 -71
- tbr_deal_finder/retailer/audible.py +53 -26
- tbr_deal_finder/retailer/chirp.py +108 -6
- tbr_deal_finder/retailer/librofm.py +58 -7
- tbr_deal_finder/retailer/models.py +18 -2
- tbr_deal_finder/retailer_deal.py +14 -5
- tbr_deal_finder/tracked_books.py +120 -0
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.6.dist-info}/METADATA +3 -2
- tbr_deal_finder-0.1.6.dist-info/RECORD +22 -0
- tbr_deal_finder-0.1.5.dist-info/RECORD +0 -21
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.6.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.6.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.5.dist-info → tbr_deal_finder-0.1.6.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/book.py
CHANGED
@@ -26,7 +26,11 @@ class Book:
|
|
26
26
|
current_price: float
|
27
27
|
timepoint: datetime
|
28
28
|
format: Union[BookFormat, str]
|
29
|
+
|
30
|
+
# Metadata really only used for tracked books.
|
31
|
+
# See get_tbr_books for more context
|
29
32
|
audiobook_isbn: str = None
|
33
|
+
audiobook_list_price: float = 0
|
30
34
|
|
31
35
|
deleted: bool = False
|
32
36
|
|
@@ -35,16 +39,16 @@ class Book:
|
|
35
39
|
normalized_authors: list[str] = None
|
36
40
|
|
37
41
|
def __post_init__(self):
|
42
|
+
self.current_price = round(self.current_price, 2)
|
43
|
+
self.list_price = round(self.list_price, 2)
|
44
|
+
self.normalized_authors = get_normalized_authors(self.authors)
|
45
|
+
|
38
46
|
if not self.deal_id:
|
39
|
-
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.
|
47
|
+
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
40
48
|
|
41
49
|
if isinstance(self.format, str):
|
42
50
|
self.format = BookFormat(self.format)
|
43
51
|
|
44
|
-
self.current_price = round(self.current_price, 2)
|
45
|
-
self.list_price = round(self.list_price, 2)
|
46
|
-
self.normalized_authors = get_normalized_authors(self.authors)
|
47
|
-
|
48
52
|
def discount(self) -> int:
|
49
53
|
return int((self.list_price/self.current_price - 1) * 100)
|
50
54
|
|
@@ -56,6 +60,10 @@ class Book:
|
|
56
60
|
def title_id(self) -> str:
|
57
61
|
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
58
62
|
|
63
|
+
@property
|
64
|
+
def full_title_str(self) -> str:
|
65
|
+
return f"{self.title}__{self.normalized_authors}"
|
66
|
+
|
59
67
|
def list_price_string(self):
|
60
68
|
return self.price_to_string(self.list_price)
|
61
69
|
|
@@ -74,6 +82,7 @@ class Book:
|
|
74
82
|
response = dataclasses.asdict(self)
|
75
83
|
response["format"] = self.format.value
|
76
84
|
del response["audiobook_isbn"]
|
85
|
+
del response["audiobook_list_price"]
|
77
86
|
del response["exists"]
|
78
87
|
del response["normalized_authors"]
|
79
88
|
|
@@ -114,3 +123,7 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
|
114
123
|
authors = [i for i in authors.split(",")]
|
115
124
|
|
116
125
|
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
126
|
+
|
127
|
+
|
128
|
+
def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
|
129
|
+
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
tbr_deal_finder/cli.py
CHANGED
@@ -9,7 +9,7 @@ import click
|
|
9
9
|
import questionary
|
10
10
|
|
11
11
|
from tbr_deal_finder.config import Config
|
12
|
-
from tbr_deal_finder.library_exports import
|
12
|
+
from tbr_deal_finder.library_exports import maybe_enrich_library_exports
|
13
13
|
from tbr_deal_finder.migrations import make_migrations
|
14
14
|
from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
|
15
15
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
@@ -124,15 +124,20 @@ def _set_locale(config: Config):
|
|
124
124
|
|
125
125
|
|
126
126
|
def _set_tracked_retailers(config: Config):
|
127
|
+
if not config.tracked_retailers:
|
128
|
+
echo_info(
|
129
|
+
"If you haven't heard of it, Chirp doesn't charge a subscription and has some great deals. \n"
|
130
|
+
"Note: I don't work for Chirp and this isn't a paid plug."
|
131
|
+
)
|
132
|
+
|
127
133
|
while True:
|
128
134
|
user_response = questionary.checkbox(
|
129
|
-
"Select the retailers you want to check deals for.\n"
|
130
|
-
"Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.\n",
|
135
|
+
"Select the retailers you want to check deals for.\n",
|
131
136
|
choices=[
|
132
137
|
questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
|
133
138
|
for retailer in RETAILER_MAP.keys()
|
134
139
|
]).ask()
|
135
|
-
if len(user_response) >
|
140
|
+
if len(user_response) > 0:
|
136
141
|
break
|
137
142
|
else:
|
138
143
|
echo_err("You must track deals for at least one retailer.")
|
@@ -185,7 +190,7 @@ def latest_deals():
|
|
185
190
|
"""Find book deals from your Library export."""
|
186
191
|
config = Config.load()
|
187
192
|
|
188
|
-
asyncio.run(
|
193
|
+
asyncio.run(maybe_enrich_library_exports(config))
|
189
194
|
|
190
195
|
db_conn = get_duckdb_conn()
|
191
196
|
results = execute_query(
|
@@ -2,15 +2,17 @@ import asyncio
|
|
2
2
|
import csv
|
3
3
|
import shutil
|
4
4
|
import tempfile
|
5
|
+
from datetime import datetime
|
6
|
+
from typing import Callable, Awaitable, Optional
|
5
7
|
|
6
8
|
from tqdm.asyncio import tqdm_asyncio
|
7
9
|
|
8
|
-
from tbr_deal_finder.book import Book, BookFormat
|
10
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
9
11
|
from tbr_deal_finder.config import Config
|
10
|
-
from tbr_deal_finder.retailer
|
12
|
+
from tbr_deal_finder.retailer import LibroFM, Chirp
|
11
13
|
|
12
14
|
|
13
|
-
def
|
15
|
+
def get_book_authors(book: dict) -> str:
|
14
16
|
if authors := book.get('Authors'):
|
15
17
|
return authors
|
16
18
|
|
@@ -21,12 +23,12 @@ def _get_book_authors(book: dict) -> str:
|
|
21
23
|
return authors
|
22
24
|
|
23
25
|
|
24
|
-
def
|
26
|
+
def get_book_title(book: dict) -> str:
|
25
27
|
title = book['Title']
|
26
28
|
return title.split("(")[0].strip()
|
27
29
|
|
28
30
|
|
29
|
-
def
|
31
|
+
def is_tbr_book(book: dict) -> bool:
|
30
32
|
if "Read Status" in book:
|
31
33
|
return book["Read Status"] == "to-read"
|
32
34
|
elif "Bookshelves" in book:
|
@@ -35,65 +37,56 @@ def _is_tbr_book(book: dict) -> bool:
|
|
35
37
|
return True
|
36
38
|
|
37
39
|
|
38
|
-
def
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
for book_dict in csv.DictReader(file):
|
45
|
-
if not _is_tbr_book(book_dict):
|
46
|
-
continue
|
47
|
-
|
48
|
-
title = _get_book_title(book_dict)
|
49
|
-
authors = _get_book_authors(book_dict)
|
50
|
-
key = f'{title}__{authors}'
|
51
|
-
|
52
|
-
if key in tbr_book_map:
|
53
|
-
continue
|
54
|
-
|
55
|
-
tbr_book_map[key] = Book(
|
56
|
-
retailer="N/A",
|
57
|
-
title=title,
|
58
|
-
authors=authors,
|
59
|
-
list_price=0,
|
60
|
-
current_price=0,
|
61
|
-
timepoint=config.run_time,
|
62
|
-
format=BookFormat.NA,
|
63
|
-
audiobook_isbn=book_dict["audiobook_isbn"],
|
64
|
-
)
|
65
|
-
return list(tbr_book_map.values())
|
66
|
-
|
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
|
+
)
|
67
46
|
|
68
|
-
async def maybe_set_library_export_audiobook_isbn(config: Config):
|
69
|
-
"""To get the price from Libro.fm for a book you need its ISBN
|
70
47
|
|
71
|
-
|
72
|
-
|
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:
|
73
63
|
"""
|
74
|
-
|
75
|
-
|
76
|
-
return
|
64
|
+
if not column_name:
|
65
|
+
column_name = attr_name
|
77
66
|
|
78
67
|
books_requiring_check_map = dict()
|
79
|
-
|
80
|
-
|
68
|
+
book_to_col_val_map = dict()
|
81
69
|
|
70
|
+
# Iterate all library export paths
|
82
71
|
for library_export_path in config.library_export_paths:
|
83
72
|
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
84
73
|
# Use csv.DictReader to get dictionaries with column headers
|
85
74
|
for book_dict in csv.DictReader(file):
|
86
|
-
if not
|
75
|
+
if not is_tbr_book(book_dict):
|
87
76
|
continue
|
88
77
|
|
89
|
-
title =
|
90
|
-
authors =
|
91
|
-
key = f'{title}__{authors}'
|
78
|
+
title = get_book_title(book_dict)
|
79
|
+
authors = get_book_authors(book_dict)
|
80
|
+
key = f'{title}__{get_normalized_authors(authors)}'
|
92
81
|
|
93
|
-
if
|
94
|
-
|
82
|
+
if column_name in book_dict:
|
83
|
+
# Keep state of value for this book/key
|
84
|
+
# in the event another export has the same book but the value is not set
|
85
|
+
book_to_col_val_map[key] = book_dict[column_name]
|
86
|
+
# Value has been found so a check no longer needs to be performed
|
95
87
|
books_requiring_check_map.pop(key, None)
|
96
|
-
elif key not in
|
88
|
+
elif key not in book_to_col_val_map:
|
89
|
+
# Not found, add the book to those requiring the column val to be set
|
97
90
|
books_requiring_check_map[key] = Book(
|
98
91
|
retailer="N/A",
|
99
92
|
title=title,
|
@@ -105,27 +98,33 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
|
|
105
98
|
)
|
106
99
|
|
107
100
|
if not books_requiring_check_map:
|
101
|
+
# Everything was resolved, nothing else to do
|
108
102
|
return
|
109
103
|
|
110
|
-
|
111
|
-
|
112
|
-
semaphore = asyncio.Semaphore(3)
|
104
|
+
semaphore = asyncio.Semaphore(5)
|
105
|
+
human_readable_name = attr_name.replace("_", " ").title()
|
113
106
|
|
114
|
-
#
|
115
|
-
|
107
|
+
# Get books with the appropriate transform applied
|
108
|
+
# Responsibility is on the callable here
|
109
|
+
enriched_books = await tqdm_asyncio.gather(
|
116
110
|
*[
|
117
|
-
|
111
|
+
get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
|
118
112
|
],
|
119
|
-
desc="Getting required
|
113
|
+
desc=f"Getting required {human_readable_name} info"
|
120
114
|
)
|
115
|
+
updated_book_map = {
|
116
|
+
b.full_title_str: b
|
117
|
+
for b in enriched_books
|
118
|
+
}
|
119
|
+
|
121
120
|
|
122
|
-
# Go back and now add the
|
121
|
+
# Go back and now add the new column where it hasn't been set
|
123
122
|
for library_export_path in config.library_export_paths:
|
124
123
|
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
125
124
|
reader = csv.DictReader(file)
|
126
|
-
field_names = list(reader.fieldnames) + [
|
125
|
+
field_names = list(reader.fieldnames) + [column_name]
|
127
126
|
file_content = [book_dict for book_dict in reader]
|
128
|
-
if not file_content or
|
127
|
+
if not file_content or column_name in file_content[0]:
|
129
128
|
continue
|
130
129
|
|
131
130
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
|
@@ -134,22 +133,73 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
|
|
134
133
|
writer.writeheader()
|
135
134
|
|
136
135
|
for book_dict in file_content:
|
137
|
-
if
|
138
|
-
title =
|
139
|
-
authors =
|
140
|
-
key = f'{title}__{authors}'
|
141
|
-
|
142
|
-
if key in
|
143
|
-
|
136
|
+
if is_tbr_book(book_dict):
|
137
|
+
title = get_book_title(book_dict)
|
138
|
+
authors = get_book_authors(book_dict)
|
139
|
+
key = f'{title}__{get_normalized_authors(authors)}'
|
140
|
+
|
141
|
+
if key in book_to_col_val_map:
|
142
|
+
col_val = book_to_col_val_map[key]
|
143
|
+
elif key in updated_book_map:
|
144
|
+
book = updated_book_map[key]
|
145
|
+
col_val = getattr(book, attr_name)
|
144
146
|
else:
|
145
|
-
|
146
|
-
audiobook_isbn = book.audiobook_isbn
|
147
|
+
col_val = ""
|
147
148
|
|
148
|
-
book_dict[
|
149
|
+
book_dict[column_name] = col_val
|
149
150
|
else:
|
150
|
-
book_dict[
|
151
|
+
book_dict[column_name] = ""
|
151
152
|
|
152
153
|
writer.writerow(book_dict)
|
153
154
|
|
154
155
|
shutil.move(temp_filename, library_export_path)
|
155
156
|
|
157
|
+
|
158
|
+
async def _maybe_set_library_export_audiobook_isbn(config: Config):
|
159
|
+
"""To get the price from Libro.fm for a book, you need its ISBN
|
160
|
+
|
161
|
+
As opposed to trying to get that every time latest-deals is run
|
162
|
+
we're just updating the export csv once to include the ISBN.
|
163
|
+
|
164
|
+
Unfortunately, we do have to get it at run time for wishlists.
|
165
|
+
"""
|
166
|
+
if "Libro.FM" not in config.tracked_retailers:
|
167
|
+
return
|
168
|
+
|
169
|
+
libro_fm = LibroFM()
|
170
|
+
await libro_fm.set_auth()
|
171
|
+
|
172
|
+
await _maybe_set_column_for_library_exports(
|
173
|
+
config,
|
174
|
+
"audiobook_isbn",
|
175
|
+
libro_fm.get_book_isbn,
|
176
|
+
)
|
177
|
+
|
178
|
+
|
179
|
+
async def _maybe_set_library_export_audiobook_list_price(config: Config):
|
180
|
+
"""Set a default list price for audiobooks
|
181
|
+
|
182
|
+
Only set if not currently set and the only audiobook retailer is Libro.FM
|
183
|
+
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
184
|
+
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
185
|
+
|
186
|
+
:param config:
|
187
|
+
:return:
|
188
|
+
"""
|
189
|
+
if not requires_audiobook_list_price_default(config):
|
190
|
+
return
|
191
|
+
|
192
|
+
chirp = Chirp()
|
193
|
+
await chirp.set_auth()
|
194
|
+
|
195
|
+
await _maybe_set_column_for_library_exports(
|
196
|
+
config,
|
197
|
+
"list_price",
|
198
|
+
chirp.get_book,
|
199
|
+
"audiobook_list_price"
|
200
|
+
)
|
201
|
+
|
202
|
+
|
203
|
+
async def maybe_enrich_library_exports(config: Config):
|
204
|
+
await _maybe_set_library_export_audiobook_isbn(config)
|
205
|
+
await _maybe_set_library_export_audiobook_list_price(config)
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import asyncio
|
2
|
+
import math
|
2
3
|
import os.path
|
3
4
|
from datetime import datetime
|
4
5
|
from textwrap import dedent
|
@@ -22,11 +23,9 @@ def login_url_callback(url: str) -> str:
|
|
22
23
|
|
23
24
|
try:
|
24
25
|
from playwright.sync_api import sync_playwright # type: ignore
|
25
|
-
use_playwright = True
|
26
26
|
except ImportError:
|
27
|
-
|
28
|
-
|
29
|
-
if use_playwright:
|
27
|
+
pass
|
28
|
+
else:
|
30
29
|
with sync_playwright() as p:
|
31
30
|
iphone = p.devices["iPhone 12 Pro"]
|
32
31
|
browser = p.webkit.launch(headless=False)
|
@@ -75,6 +74,23 @@ class Audible(Retailer):
|
|
75
74
|
def name(self) -> str:
|
76
75
|
return "Audible"
|
77
76
|
|
77
|
+
@property
|
78
|
+
def format(self) -> BookFormat:
|
79
|
+
return BookFormat.AUDIOBOOK
|
80
|
+
|
81
|
+
async def set_auth(self):
|
82
|
+
if not os.path.exists(_AUTH_PATH):
|
83
|
+
auth = audible.Authenticator.from_login_external(
|
84
|
+
locale=Config.locale,
|
85
|
+
login_url_callback=login_url_callback
|
86
|
+
)
|
87
|
+
|
88
|
+
# Save credentials to file
|
89
|
+
auth.to_file(_AUTH_PATH)
|
90
|
+
|
91
|
+
self._auth = audible.Authenticator.from_file(_AUTH_PATH)
|
92
|
+
self._client = audible.AsyncClient(auth=self._auth)
|
93
|
+
|
78
94
|
async def get_book(
|
79
95
|
self,
|
80
96
|
target: Book,
|
@@ -95,19 +111,7 @@ class Audible(Retailer):
|
|
95
111
|
]
|
96
112
|
)
|
97
113
|
|
98
|
-
|
99
|
-
return Book(
|
100
|
-
retailer=self.name,
|
101
|
-
title=title,
|
102
|
-
authors=authors,
|
103
|
-
list_price=0,
|
104
|
-
current_price=0,
|
105
|
-
timepoint=runtime,
|
106
|
-
format=BookFormat.AUDIOBOOK,
|
107
|
-
exists=False,
|
108
|
-
)
|
109
|
-
|
110
|
-
for product in match["products"]:
|
114
|
+
for product in match.get("products", []):
|
111
115
|
if product["title"] != title:
|
112
116
|
continue
|
113
117
|
|
@@ -132,15 +136,38 @@ class Audible(Retailer):
|
|
132
136
|
exists=False,
|
133
137
|
)
|
134
138
|
|
135
|
-
async def
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
139
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
140
|
+
wishlist_books = []
|
141
|
+
|
142
|
+
page = 0
|
143
|
+
total_pages = 1
|
144
|
+
page_size = 50
|
145
|
+
while page < total_pages:
|
146
|
+
response = await self._client.get(
|
147
|
+
"1.0/wishlist",
|
148
|
+
num_results=page_size,
|
149
|
+
page=page,
|
150
|
+
response_groups=[
|
151
|
+
"contributors, product_attrs, product_desc, product_extended_attrs"
|
152
|
+
]
|
140
153
|
)
|
141
154
|
|
142
|
-
|
143
|
-
|
155
|
+
for audiobook in response.get("products", []):
|
156
|
+
authors = [author["name"] for author in audiobook["authors"]]
|
157
|
+
wishlist_books.append(
|
158
|
+
Book(
|
159
|
+
retailer=self.name,
|
160
|
+
title=audiobook["title"],
|
161
|
+
authors=", ".join(authors),
|
162
|
+
list_price=1,
|
163
|
+
current_price=1,
|
164
|
+
timepoint=config.run_time,
|
165
|
+
format=self.format,
|
166
|
+
audiobook_isbn=audiobook["isbn"],
|
167
|
+
)
|
168
|
+
)
|
144
169
|
|
145
|
-
|
146
|
-
|
170
|
+
page += 1
|
171
|
+
total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
|
172
|
+
|
173
|
+
return wishlist_books
|
@@ -1,21 +1,87 @@
|
|
1
1
|
import asyncio
|
2
|
-
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
from datetime import datetime, timedelta
|
3
5
|
|
4
6
|
import aiohttp
|
7
|
+
import click
|
5
8
|
|
9
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
10
|
+
from tbr_deal_finder.config import Config
|
6
11
|
from tbr_deal_finder.retailer.models import Retailer
|
7
12
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
8
|
-
from tbr_deal_finder.utils import currency_to_float
|
13
|
+
from tbr_deal_finder.utils import currency_to_float, echo_err
|
9
14
|
|
10
15
|
|
11
16
|
class Chirp(Retailer):
|
12
17
|
# Static because url for other locales just redirects to .com
|
13
|
-
_url: str = "https://
|
18
|
+
_url: str = "https://api.chirpbooks.com/api/graphql"
|
19
|
+
USER_AGENT = "ChirpBooks/5.13.9 (Android)"
|
20
|
+
|
21
|
+
def __init__(self):
|
22
|
+
self.auth_token = None
|
14
23
|
|
15
24
|
@property
|
16
25
|
def name(self) -> str:
|
17
26
|
return "Chirp"
|
18
27
|
|
28
|
+
@property
|
29
|
+
def format(self) -> BookFormat:
|
30
|
+
return BookFormat.AUDIOBOOK
|
31
|
+
|
32
|
+
async def make_request(self, request_type: str, **kwargs) -> dict:
|
33
|
+
headers = kwargs.pop("headers", {})
|
34
|
+
headers["Accept"] = "application/json"
|
35
|
+
headers["Content-Type"] = "application/json"
|
36
|
+
headers["User-Agent"] = self.USER_AGENT
|
37
|
+
if self.auth_token:
|
38
|
+
headers["authorization"] = f"Bearer {self.auth_token}"
|
39
|
+
|
40
|
+
async with aiohttp.ClientSession() as http_client:
|
41
|
+
response = await http_client.request(
|
42
|
+
request_type.upper(),
|
43
|
+
self._url,
|
44
|
+
headers=headers,
|
45
|
+
**kwargs
|
46
|
+
)
|
47
|
+
if response.ok:
|
48
|
+
return await response.json()
|
49
|
+
else:
|
50
|
+
return {}
|
51
|
+
|
52
|
+
async def set_auth(self):
|
53
|
+
auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
|
54
|
+
if os.path.exists(auth_path):
|
55
|
+
with open(auth_path, "r") as f:
|
56
|
+
auth_info = json.load(f)
|
57
|
+
if auth_info:
|
58
|
+
token_created_at = datetime.fromtimestamp(auth_info["created_at"])
|
59
|
+
max_token_age = datetime.now() - timedelta(days=5)
|
60
|
+
if token_created_at > max_token_age:
|
61
|
+
self.auth_token = auth_info["data"]["signIn"]["user"]["token"]
|
62
|
+
return
|
63
|
+
|
64
|
+
response = await self.make_request(
|
65
|
+
"POST",
|
66
|
+
json={
|
67
|
+
"query": "mutation signIn($email: String!, $password: String!) { signIn(email: $email, password: $password) { user { id token webToken email } } }",
|
68
|
+
"variables": {
|
69
|
+
"email": click.prompt("Chirp account email"),
|
70
|
+
"password": click.prompt("Chirp Password", hide_input=True),
|
71
|
+
}
|
72
|
+
}
|
73
|
+
)
|
74
|
+
if not response:
|
75
|
+
echo_err("Chirp login failed, please try again.")
|
76
|
+
await self.set_auth()
|
77
|
+
|
78
|
+
# Set token for future requests during the current execution
|
79
|
+
self.auth_token = response["data"]["signIn"]["user"]["token"]
|
80
|
+
|
81
|
+
response["created_at"] = datetime.now().timestamp()
|
82
|
+
with open(auth_path, "w") as f:
|
83
|
+
json.dump(response, f)
|
84
|
+
|
19
85
|
async def get_book(
|
20
86
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
21
87
|
) -> Book:
|
@@ -51,9 +117,10 @@ class Chirp(Retailer):
|
|
51
117
|
if not book["currentProduct"]:
|
52
118
|
continue
|
53
119
|
|
120
|
+
normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
|
54
121
|
if (
|
55
122
|
book["displayTitle"] == title
|
56
|
-
and
|
123
|
+
and any(author in normalized_authors for author in target.normalized_authors)
|
57
124
|
):
|
58
125
|
return Book(
|
59
126
|
retailer=self.name,
|
@@ -76,5 +143,40 @@ class Chirp(Retailer):
|
|
76
143
|
exists=False,
|
77
144
|
)
|
78
145
|
|
79
|
-
async def
|
80
|
-
|
146
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
147
|
+
wishlist_books = []
|
148
|
+
page = 1
|
149
|
+
|
150
|
+
while True:
|
151
|
+
response = await self.make_request(
|
152
|
+
"POST",
|
153
|
+
json={
|
154
|
+
"query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query FetchWishlistDealAudiobooks($page:Int,$pageSize:Int){currentUserWishlist{paginatedItems(filter:\"currently_promoted\",sort:\"promotion_end_date\",salability:current_or_future){totalCount objects(page:$page,pageSize:$pageSize){... on WishlistItem{id audiobook{...audiobookFields currentProduct{...productFields}}}}}}}",
|
155
|
+
"variables": {"page": page, "pageSize": 15},
|
156
|
+
"operationName": "FetchWishlistDealAudiobooks"
|
157
|
+
}
|
158
|
+
)
|
159
|
+
|
160
|
+
audiobooks = response.get(
|
161
|
+
"data", {}
|
162
|
+
).get("currentUserWishlist", {}).get("paginatedItems", {}).get("objects", [])
|
163
|
+
|
164
|
+
if not audiobooks:
|
165
|
+
return wishlist_books
|
166
|
+
|
167
|
+
for book in audiobooks:
|
168
|
+
audiobook = book["audiobook"]
|
169
|
+
authors = [author["name"] for author in audiobook["allAuthors"]]
|
170
|
+
wishlist_books.append(
|
171
|
+
Book(
|
172
|
+
retailer=self.name,
|
173
|
+
title=audiobook["displayTitle"],
|
174
|
+
authors=", ".join(authors),
|
175
|
+
list_price=1,
|
176
|
+
current_price=1,
|
177
|
+
timepoint=config.run_time,
|
178
|
+
format=self.format,
|
179
|
+
)
|
180
|
+
)
|
181
|
+
|
182
|
+
page += 1
|
@@ -3,12 +3,12 @@ import json
|
|
3
3
|
import os
|
4
4
|
import urllib.parse
|
5
5
|
from datetime import datetime, timedelta
|
6
|
-
from typing import Union
|
7
6
|
|
8
7
|
import aiohttp
|
9
8
|
import click
|
10
9
|
|
11
10
|
from tbr_deal_finder import TBR_DEALS_PATH
|
11
|
+
from tbr_deal_finder.config import Config
|
12
12
|
from tbr_deal_finder.retailer.models import Retailer
|
13
13
|
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
14
14
|
from tbr_deal_finder.utils import currency_to_float
|
@@ -33,11 +33,16 @@ class LibroFM(Retailer):
|
|
33
33
|
def name(self) -> str:
|
34
34
|
return "Libro.FM"
|
35
35
|
|
36
|
+
@property
|
37
|
+
def format(self) -> BookFormat:
|
38
|
+
return BookFormat.AUDIOBOOK
|
39
|
+
|
36
40
|
async def make_request(self, url_path: str, request_type: str, **kwargs) -> dict:
|
37
41
|
url = urllib.parse.urljoin(self.BASE_URL, url_path)
|
38
42
|
headers = kwargs.pop("headers", {})
|
39
43
|
headers["User-Agent"] = self.USER_AGENT
|
40
|
-
|
44
|
+
if self.auth_token:
|
45
|
+
headers["authorization"] = f"Bearer {self.auth_token}"
|
41
46
|
|
42
47
|
async with aiohttp.ClientSession() as http_client:
|
43
48
|
response = await http_client.request(
|
@@ -75,7 +80,9 @@ class LibroFM(Retailer):
|
|
75
80
|
with open(auth_path, "w") as f:
|
76
81
|
json.dump(response, f)
|
77
82
|
|
78
|
-
async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) ->
|
83
|
+
async def get_book_isbn(self, book: Book, runtime: datetime, semaphore: asyncio.Semaphore) -> Book:
|
84
|
+
# runtime isn't used but get_book_isbn must follow the get_book method signature.
|
85
|
+
|
79
86
|
title = book.title
|
80
87
|
|
81
88
|
async with semaphore:
|
@@ -90,15 +97,25 @@ class LibroFM(Retailer):
|
|
90
97
|
)
|
91
98
|
|
92
99
|
for b in response["audiobook_collection"]["audiobooks"]:
|
93
|
-
|
100
|
+
normalized_authors = get_normalized_authors(b["authors"])
|
101
|
+
|
102
|
+
if (
|
103
|
+
title == b["title"]
|
104
|
+
and any(author in normalized_authors for author in book.normalized_authors)
|
105
|
+
):
|
94
106
|
book.audiobook_isbn = b["isbn"]
|
95
|
-
|
107
|
+
break
|
96
108
|
|
97
|
-
return
|
109
|
+
return book
|
98
110
|
|
99
111
|
async def get_book(
|
100
112
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
101
113
|
) -> Book:
|
114
|
+
if target.format == BookFormat.AUDIOBOOK and not target.audiobook_isbn:
|
115
|
+
# When "format" is AUDIOBOOK here that means the target was pulled from an audiobook retailer wishlist
|
116
|
+
# In this flow, there is no attempt to resolve the isbn ahead of time, so it's done here instead.
|
117
|
+
await self.get_book_isbn(target, runtime, semaphore)
|
118
|
+
|
102
119
|
if not target.audiobook_isbn:
|
103
120
|
return Book(
|
104
121
|
retailer=self.name,
|
@@ -122,7 +139,7 @@ class LibroFM(Retailer):
|
|
122
139
|
retailer=self.name,
|
123
140
|
title=target.title,
|
124
141
|
authors=target.authors,
|
125
|
-
list_price=
|
142
|
+
list_price=target.audiobook_list_price,
|
126
143
|
current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
|
127
144
|
timepoint=runtime,
|
128
145
|
format=BookFormat.AUDIOBOOK,
|
@@ -138,3 +155,37 @@ class LibroFM(Retailer):
|
|
138
155
|
format=BookFormat.AUDIOBOOK,
|
139
156
|
exists=False,
|
140
157
|
)
|
158
|
+
|
159
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
160
|
+
wishlist_books = []
|
161
|
+
|
162
|
+
page = 1
|
163
|
+
total_pages = 1
|
164
|
+
while page <= total_pages:
|
165
|
+
response = await self.make_request(
|
166
|
+
f"api/v10/explore/wishlist",
|
167
|
+
"GET",
|
168
|
+
params=dict(page=2)
|
169
|
+
)
|
170
|
+
wishlist = response.get("data", {}).get("wishlist", {})
|
171
|
+
if not wishlist:
|
172
|
+
return []
|
173
|
+
|
174
|
+
for book in wishlist.get("audiobooks", []):
|
175
|
+
wishlist_books.append(
|
176
|
+
Book(
|
177
|
+
retailer=self.name,
|
178
|
+
title=book["title"],
|
179
|
+
authors=", ".join(book["authors"]),
|
180
|
+
list_price=1,
|
181
|
+
current_price=1,
|
182
|
+
timepoint=config.run_time,
|
183
|
+
format=self.format,
|
184
|
+
audiobook_isbn=book["isbn"],
|
185
|
+
)
|
186
|
+
)
|
187
|
+
|
188
|
+
page += 1
|
189
|
+
total_pages = wishlist["total_pages"]
|
190
|
+
|
191
|
+
return wishlist_books
|
@@ -2,7 +2,8 @@ import abc
|
|
2
2
|
import asyncio
|
3
3
|
from datetime import datetime
|
4
4
|
|
5
|
-
from tbr_deal_finder.book import Book
|
5
|
+
from tbr_deal_finder.book import Book, BookFormat
|
6
|
+
from tbr_deal_finder.config import Config
|
6
7
|
|
7
8
|
|
8
9
|
class Retailer(abc.ABC):
|
@@ -12,6 +13,21 @@ class Retailer(abc.ABC):
|
|
12
13
|
def name(self) -> str:
|
13
14
|
raise NotImplementedError
|
14
15
|
|
16
|
+
@property
|
17
|
+
def format(self) -> BookFormat:
|
18
|
+
"""The format of the books they sell.
|
19
|
+
|
20
|
+
For example,
|
21
|
+
Audible would be audiobooks
|
22
|
+
Kindle would be ebooks
|
23
|
+
|
24
|
+
:return:
|
25
|
+
"""
|
26
|
+
raise NotImplementedError
|
27
|
+
|
28
|
+
async def set_auth(self):
|
29
|
+
raise NotImplementedError
|
30
|
+
|
15
31
|
async def get_book(
|
16
32
|
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
17
33
|
) -> Book:
|
@@ -31,7 +47,7 @@ class Retailer(abc.ABC):
|
|
31
47
|
"""
|
32
48
|
raise NotImplementedError
|
33
49
|
|
34
|
-
async def
|
50
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
35
51
|
raise NotImplementedError
|
36
52
|
|
37
53
|
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -6,9 +6,9 @@ import click
|
|
6
6
|
import pandas as pd
|
7
7
|
from tqdm.asyncio import tqdm_asyncio
|
8
8
|
|
9
|
-
from tbr_deal_finder.book import Book, get_active_deals
|
9
|
+
from tbr_deal_finder.book import Book, get_active_deals, BookFormat
|
10
10
|
from tbr_deal_finder.config import Config
|
11
|
-
from tbr_deal_finder.
|
11
|
+
from tbr_deal_finder.tracked_books import get_tbr_books
|
12
12
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
13
13
|
from tbr_deal_finder.retailer.models import Retailer
|
14
14
|
from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
|
@@ -73,7 +73,7 @@ def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
|
|
73
73
|
async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
|
74
74
|
"""Get Books with limited concurrency.
|
75
75
|
|
76
|
-
- Creates
|
76
|
+
- Creates semaphore to limit concurrent requests.
|
77
77
|
- Creates a list to store the response.
|
78
78
|
- Creates a list to store unresolved books.
|
79
79
|
|
@@ -163,14 +163,23 @@ async def get_latest_deals(config: Config):
|
|
163
163
|
"""
|
164
164
|
|
165
165
|
books: list[Book] = []
|
166
|
-
tbr_books = get_tbr_books(config)
|
166
|
+
tbr_books = await get_tbr_books(config)
|
167
|
+
|
167
168
|
for retailer_str in config.tracked_retailers:
|
168
169
|
retailer = RETAILER_MAP[retailer_str]()
|
169
170
|
await retailer.set_auth()
|
170
171
|
|
172
|
+
# Don't check on deals in a specified format
|
173
|
+
# that does not match the format the retailer sells
|
174
|
+
relevant_tbr_books = [
|
175
|
+
book
|
176
|
+
for book in tbr_books
|
177
|
+
if book.format == BookFormat.NA or book.format == retailer.format
|
178
|
+
]
|
179
|
+
|
171
180
|
echo_info(f"Getting deals from {retailer.name}")
|
172
181
|
click.echo("\n---------------")
|
173
|
-
books.extend(await _get_books(config, retailer,
|
182
|
+
books.extend(await _get_books(config, retailer, relevant_tbr_books))
|
174
183
|
click.echo("---------------\n")
|
175
184
|
|
176
185
|
_apply_proper_list_prices(books)
|
@@ -0,0 +1,120 @@
|
|
1
|
+
import asyncio
|
2
|
+
import csv
|
3
|
+
|
4
|
+
from tqdm.asyncio import tqdm_asyncio
|
5
|
+
|
6
|
+
from tbr_deal_finder.book import Book, BookFormat, get_title_id
|
7
|
+
from tbr_deal_finder.retailer import Chirp, RETAILER_MAP
|
8
|
+
from tbr_deal_finder.config import Config
|
9
|
+
from tbr_deal_finder.library_exports import (
|
10
|
+
get_book_authors,
|
11
|
+
get_book_title,
|
12
|
+
is_tbr_book,
|
13
|
+
requires_audiobook_list_price_default
|
14
|
+
)
|
15
|
+
from tbr_deal_finder.retailer.models import Retailer
|
16
|
+
from tbr_deal_finder.utils import currency_to_float
|
17
|
+
|
18
|
+
|
19
|
+
def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
20
|
+
"""Adds tbr books in the library export to the provided tbr_book_map
|
21
|
+
|
22
|
+
:param config:
|
23
|
+
:param tbr_book_map:
|
24
|
+
:return:
|
25
|
+
"""
|
26
|
+
for library_export_path in config.library_export_paths:
|
27
|
+
|
28
|
+
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
29
|
+
# Use csv.DictReader to get dictionaries with column headers
|
30
|
+
for book_dict in csv.DictReader(file):
|
31
|
+
if not is_tbr_book(book_dict):
|
32
|
+
continue
|
33
|
+
|
34
|
+
title = get_book_title(book_dict)
|
35
|
+
authors = get_book_authors(book_dict)
|
36
|
+
|
37
|
+
key = get_title_id(title, authors, BookFormat.NA)
|
38
|
+
if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
|
39
|
+
continue
|
40
|
+
|
41
|
+
tbr_book_map[key] = Book(
|
42
|
+
retailer="N/A",
|
43
|
+
title=title,
|
44
|
+
authors=authors,
|
45
|
+
list_price=0,
|
46
|
+
current_price=0,
|
47
|
+
timepoint=config.run_time,
|
48
|
+
format=BookFormat.NA,
|
49
|
+
audiobook_isbn=book_dict.get("audiobook_isbn"),
|
50
|
+
audiobook_list_price=currency_to_float(book_dict.get("audiobook_list_price") or 0),
|
51
|
+
)
|
52
|
+
|
53
|
+
|
54
|
+
async def _apply_dynamic_audiobook_list_price(config: Config, tbr_book_map: dict[str: Book]):
|
55
|
+
target_books = [
|
56
|
+
book
|
57
|
+
for book in tbr_book_map.values()
|
58
|
+
if book.format == BookFormat.AUDIOBOOK
|
59
|
+
]
|
60
|
+
if not target_books:
|
61
|
+
return
|
62
|
+
|
63
|
+
chirp = Chirp()
|
64
|
+
semaphore = asyncio.Semaphore(5)
|
65
|
+
books_with_pricing: list[Book] = await tqdm_asyncio.gather(
|
66
|
+
*[
|
67
|
+
chirp.get_book(book, config.run_time, semaphore)
|
68
|
+
for book in target_books
|
69
|
+
],
|
70
|
+
desc=f"Getting list prices for Libro.FM wishlist"
|
71
|
+
)
|
72
|
+
for book in books_with_pricing:
|
73
|
+
tbr_book_map[book.title_id].audiobook_list_price = book.list_price
|
74
|
+
|
75
|
+
|
76
|
+
async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
|
77
|
+
"""Adds wishlist books in the library export to the provided tbr_book_map
|
78
|
+
Books added here has the format the retailer sells (e.g. Audiobook)
|
79
|
+
so deals are only checked for retailers with that type.
|
80
|
+
|
81
|
+
For example,
|
82
|
+
I as a user have Dune on my audible wishlist.
|
83
|
+
I want to see deals for it on Libro because it's an audiobook.
|
84
|
+
I don't want to see Kindle deals.
|
85
|
+
|
86
|
+
:param config:
|
87
|
+
:param tbr_book_map:
|
88
|
+
:return:
|
89
|
+
"""
|
90
|
+
for retailer_str in config.tracked_retailers:
|
91
|
+
retailer: Retailer = RETAILER_MAP[retailer_str]()
|
92
|
+
await retailer.set_auth()
|
93
|
+
|
94
|
+
for book in (await retailer.get_wishlist(config)):
|
95
|
+
na_key = get_title_id(book.title, book.authors, BookFormat.NA)
|
96
|
+
if na_key in tbr_book_map:
|
97
|
+
continue
|
98
|
+
|
99
|
+
key = book.title_id
|
100
|
+
if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
|
101
|
+
continue
|
102
|
+
|
103
|
+
tbr_book_map[key] = book
|
104
|
+
|
105
|
+
if requires_audiobook_list_price_default(config):
|
106
|
+
await _apply_dynamic_audiobook_list_price(config, tbr_book_map)
|
107
|
+
|
108
|
+
|
109
|
+
async def get_tbr_books(config: Config) -> list[Book]:
|
110
|
+
tbr_book_map: dict[str: Book] = {}
|
111
|
+
|
112
|
+
# Get TBRs specified in the user library (StoryGraph/GoodReads) export
|
113
|
+
_library_export_tbr_books(config, tbr_book_map)
|
114
|
+
|
115
|
+
# Pull wishlist from tracked retailers
|
116
|
+
await _retailer_wishlist(config, tbr_book_map)
|
117
|
+
|
118
|
+
return list(tbr_book_map.values())
|
119
|
+
|
120
|
+
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.6
|
4
4
|
Summary: Track price drops and find deals on books in your TBR list across audiobook and ebook formats.
|
5
5
|
License: MIT
|
6
6
|
License-File: LICENSE
|
@@ -21,7 +21,8 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
21
21
|
|
22
22
|
## Features
|
23
23
|
- Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
24
|
-
- Supports multiple of the library exports above
|
24
|
+
- Supports multiple of the library exports above
|
25
|
+
- Tracks deals on the wishlist of all your configured retailers like audible
|
25
26
|
- Supports multiple locales and currencies
|
26
27
|
- Finds the latest and active deals from supported sellers
|
27
28
|
- Simple CLI interface for setup and usage
|
@@ -0,0 +1,22 @@
|
|
1
|
+
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
+
tbr_deal_finder/book.py,sha256=ZnwuIU2-rP0UC12fSC_HiTXpmgBp5XrRiFe82OLQ-E0,3786
|
3
|
+
tbr_deal_finder/cli.py,sha256=jIwzyESLGc77Wyxsv4XjEfMHZHjQI_PTmBLamc9tiV0,7284
|
4
|
+
tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
|
5
|
+
tbr_deal_finder/library_exports.py,sha256=Hupx3mJyhvqXEslR2R3ifG9ykSKxkOb-H3gGK_CRx68,7133
|
6
|
+
tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
|
7
|
+
tbr_deal_finder/retailer_deal.py,sha256=wMziFXCvrJQ_i4IdsXVPlaXnUIeGTRPt_rwbPfqX2FE,6742
|
8
|
+
tbr_deal_finder/tracked_books.py,sha256=EKecARSOMPPkmjSSWfWQw9B-TnoSd3-6AQepc2EYTz0,4031
|
9
|
+
tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
|
10
|
+
tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
|
11
|
+
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
|
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/audible.py,sha256=AY8jippIQe0XqCXk9iLb3CHADIYIG3orlctNQRSv2q8,5315
|
15
|
+
tbr_deal_finder/retailer/chirp.py,sha256=BVtHsrM0nsMmT2fxDnUliXVWfY2xZY8TR3FWZzyaxIA,8042
|
16
|
+
tbr_deal_finder/retailer/librofm.py,sha256=ZiowAIpDYnuH6KREdPK874t-Handlr0jZ9Mj0QMVGis,6428
|
17
|
+
tbr_deal_finder/retailer/models.py,sha256=wAGZtp0BWz9vlZCcWZqll8gwXUP6-6oFtsWv3gCXEHM,1415
|
18
|
+
tbr_deal_finder-0.1.6.dist-info/METADATA,sha256=CbRtsumWOcJ_VaabBoCqzGURzKjCOvvdk5koY11ukZE,4215
|
19
|
+
tbr_deal_finder-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
20
|
+
tbr_deal_finder-0.1.6.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
21
|
+
tbr_deal_finder-0.1.6.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
22
|
+
tbr_deal_finder-0.1.6.dist-info/RECORD,,
|
@@ -1,21 +0,0 @@
|
|
1
|
-
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
-
tbr_deal_finder/book.py,sha256=2MQirkxDIVKdQQ07U56zwOw45rC8KH-5aC932_X9dhE,3333
|
3
|
-
tbr_deal_finder/cli.py,sha256=1O_smeeIJSLWBg5vzSGIv-LDU8RYXHIBnrX_wOhuIKM,7168
|
4
|
-
tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
|
5
|
-
tbr_deal_finder/library_exports.py,sha256=FJXBQV_9H5AI_cwNuGjvGQxgQ1RLlpldmDBwc_PsiK4,5474
|
6
|
-
tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
|
7
|
-
tbr_deal_finder/retailer_deal.py,sha256=mo3LkWWn6KENgaL9ZnIO24l-T43HZHbKLPG_D0dOsqU,6432
|
8
|
-
tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
|
9
|
-
tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
|
10
|
-
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
|
11
|
-
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
12
|
-
tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
|
13
|
-
tbr_deal_finder/retailer/audible.py,sha256=7z7rDQBcCwOhdATU4BJjsJ-QBP0HnxscMSGPmzN_K2k,4408
|
14
|
-
tbr_deal_finder/retailer/chirp.py,sha256=_ckdNPQT39Qy6ixI7sZLpEEDDgbxZn6_t4pQOiCULiE,3614
|
15
|
-
tbr_deal_finder/retailer/librofm.py,sha256=YZojG8B0WftQFphJjNWvMSBVVd8oGsg8cr00jKwt9xw,4590
|
16
|
-
tbr_deal_finder/retailer/models.py,sha256=zEwyM_0ildB8p38sxfpE6p2dIvtwAjeaul-GkJlk2Fo,1012
|
17
|
-
tbr_deal_finder-0.1.5.dist-info/METADATA,sha256=d9sfj3B4vbzXeysL_xHsKvoCUo15SI1x2meI-tow0Cg,4138
|
18
|
-
tbr_deal_finder-0.1.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
19
|
-
tbr_deal_finder-0.1.5.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
20
|
-
tbr_deal_finder-0.1.5.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
21
|
-
tbr_deal_finder-0.1.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|