tbr-deal-finder 0.1.8__py3-none-any.whl → 0.2.1__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 +38 -9
- tbr_deal_finder/cli.py +2 -1
- tbr_deal_finder/config.py +1 -1
- tbr_deal_finder/migrations.py +26 -0
- tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
- tbr_deal_finder/retailer/__init__.py +2 -0
- tbr_deal_finder/retailer/amazon.py +10 -4
- tbr_deal_finder/retailer/audible.py +2 -3
- tbr_deal_finder/retailer/chirp.py +26 -24
- tbr_deal_finder/retailer/kindle.py +153 -0
- tbr_deal_finder/retailer/librofm.py +22 -17
- tbr_deal_finder/retailer/models.py +36 -0
- tbr_deal_finder/retailer_deal.py +24 -7
- tbr_deal_finder/tracked_books.py +87 -6
- {tbr_deal_finder-0.1.8.dist-info → tbr_deal_finder-0.2.1.dist-info}/METADATA +5 -2
- tbr_deal_finder-0.2.1.dist-info/RECORD +25 -0
- tbr_deal_finder-0.1.8.dist-info/RECORD +0 -23
- {tbr_deal_finder-0.1.8.dist-info → tbr_deal_finder-0.2.1.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.8.dist-info → tbr_deal_finder-0.2.1.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.8.dist-info → tbr_deal_finder-0.2.1.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/book.py
CHANGED
@@ -8,7 +8,7 @@ from Levenshtein import ratio
|
|
8
8
|
from unidecode import unidecode
|
9
9
|
|
10
10
|
from tbr_deal_finder.config import Config
|
11
|
-
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
|
12
12
|
|
13
13
|
_AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
|
14
14
|
|
@@ -36,7 +36,7 @@ class Book:
|
|
36
36
|
exists: bool = True,
|
37
37
|
):
|
38
38
|
self.retailer = retailer
|
39
|
-
self.title = title
|
39
|
+
self.title = get_normalized_title(title)
|
40
40
|
self.authors = authors
|
41
41
|
self.timepoint = timepoint
|
42
42
|
|
@@ -55,7 +55,10 @@ class Book:
|
|
55
55
|
self.format = format
|
56
56
|
|
57
57
|
def discount(self) -> int:
|
58
|
-
|
58
|
+
if not self.current_price:
|
59
|
+
return 100
|
60
|
+
|
61
|
+
return int((1 - self.current_price/self.list_price) * 100)
|
59
62
|
|
60
63
|
@staticmethod
|
61
64
|
def price_to_string(price: float) -> str:
|
@@ -127,6 +130,15 @@ class Book:
|
|
127
130
|
"book_id": self.title_id,
|
128
131
|
}
|
129
132
|
|
133
|
+
def unknown_book_dict(self):
|
134
|
+
return {
|
135
|
+
"retailer": self.retailer,
|
136
|
+
"title": self.title,
|
137
|
+
"authors": self.authors,
|
138
|
+
"format": self.format.value,
|
139
|
+
"book_id": self.deal_id,
|
140
|
+
}
|
141
|
+
|
130
142
|
|
131
143
|
def get_deals_found_at(timepoint: datetime) -> list[Book]:
|
132
144
|
db_conn = get_duckdb_conn()
|
@@ -148,13 +160,26 @@ def get_active_deals() -> list[Book]:
|
|
148
160
|
|
149
161
|
|
150
162
|
def print_books(books: list[Book]):
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
163
|
+
audiobooks = [book for book in books if book.format == BookFormat.AUDIOBOOK]
|
164
|
+
audiobooks = sorted(audiobooks, key=lambda book: book.deal_id)
|
165
|
+
|
166
|
+
ebooks = [book for book in books if book.format == BookFormat.EBOOK]
|
167
|
+
ebooks = sorted(ebooks, key=lambda book: book.deal_id)
|
168
|
+
|
169
|
+
for books_in_format in [audiobooks, ebooks]:
|
170
|
+
if not books_in_format:
|
171
|
+
continue
|
172
|
+
|
173
|
+
init_book = books_in_format[0]
|
174
|
+
echo_info(f"\n\n{init_book.format.value} Deals:")
|
156
175
|
|
157
|
-
|
176
|
+
prior_title_id = init_book.title_id
|
177
|
+
for book in books_in_format:
|
178
|
+
if prior_title_id != book.title_id:
|
179
|
+
prior_title_id = book.title_id
|
180
|
+
click.echo()
|
181
|
+
|
182
|
+
click.echo(str(book))
|
158
183
|
|
159
184
|
|
160
185
|
def get_full_title_str(title: str, authors: Union[list, str]) -> str:
|
@@ -165,6 +190,10 @@ def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat)
|
|
165
190
|
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
166
191
|
|
167
192
|
|
193
|
+
def get_normalized_title(title: str) -> str:
|
194
|
+
return title.split(":")[0].split("(")[0].strip()
|
195
|
+
|
196
|
+
|
168
197
|
def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
169
198
|
if isinstance(authors, str):
|
170
199
|
authors = [i for i in authors.split(",")]
|
tbr_deal_finder/cli.py
CHANGED
@@ -13,7 +13,7 @@ from tbr_deal_finder.migrations import make_migrations
|
|
13
13
|
from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
|
14
14
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
15
15
|
from tbr_deal_finder.retailer_deal import get_latest_deals
|
16
|
-
from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books
|
16
|
+
from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books, clear_unknown_books
|
17
17
|
from tbr_deal_finder.utils import (
|
18
18
|
echo_err,
|
19
19
|
echo_info,
|
@@ -183,6 +183,7 @@ def setup():
|
|
183
183
|
# Retailers may have changed causing some books to need reprocessing
|
184
184
|
config = Config.load()
|
185
185
|
reprocess_incomplete_tbr_books(config)
|
186
|
+
clear_unknown_books()
|
186
187
|
|
187
188
|
|
188
189
|
@cli.command()
|
tbr_deal_finder/config.py
CHANGED
@@ -26,7 +26,7 @@ class Config:
|
|
26
26
|
library_export_paths: list[str]
|
27
27
|
tracked_retailers: list[str]
|
28
28
|
max_price: float = 8.0
|
29
|
-
min_discount: int =
|
29
|
+
min_discount: int = 30
|
30
30
|
run_time: datetime = datetime.now()
|
31
31
|
|
32
32
|
locale: str = "us" # This will be set as a class attribute below
|
tbr_deal_finder/migrations.py
CHANGED
@@ -62,6 +62,32 @@ _MIGRATIONS = [
|
|
62
62
|
);
|
63
63
|
"""
|
64
64
|
),
|
65
|
+
TableMigration(
|
66
|
+
version=1,
|
67
|
+
table_name="unknown_book",
|
68
|
+
sql="""
|
69
|
+
CREATE TABLE unknown_book
|
70
|
+
(
|
71
|
+
retailer VARCHAR,
|
72
|
+
title VARCHAR,
|
73
|
+
authors VARCHAR,
|
74
|
+
format VARCHAR,
|
75
|
+
book_id VARCHAR
|
76
|
+
);
|
77
|
+
"""
|
78
|
+
),
|
79
|
+
TableMigration(
|
80
|
+
version=1,
|
81
|
+
table_name="unknown_book_run_history",
|
82
|
+
sql="""
|
83
|
+
CREATE TABLE unknown_book_run_history
|
84
|
+
(
|
85
|
+
timepoint TIMESTAMP_NS,
|
86
|
+
ran_successfully BOOLEAN,
|
87
|
+
details VARCHAR
|
88
|
+
);
|
89
|
+
"""
|
90
|
+
),
|
65
91
|
]
|
66
92
|
|
67
93
|
|
@@ -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
|
}
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import sys
|
1
2
|
import os.path
|
2
3
|
|
3
4
|
import audible
|
@@ -5,11 +6,16 @@ import click
|
|
5
6
|
from audible.login import build_init_cookies
|
6
7
|
from textwrap import dedent
|
7
8
|
|
9
|
+
if sys.platform != 'win32':
|
10
|
+
# Breaks Windows support but required for Mac
|
11
|
+
# Untested on Linux
|
12
|
+
import readline # type: ignore
|
13
|
+
|
8
14
|
from tbr_deal_finder import TBR_DEALS_PATH
|
9
15
|
from tbr_deal_finder.config import Config
|
10
16
|
from tbr_deal_finder.retailer.models import Retailer
|
11
17
|
|
12
|
-
|
18
|
+
AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
|
13
19
|
|
14
20
|
|
15
21
|
def login_url_callback(url: str) -> str:
|
@@ -65,15 +71,15 @@ class Amazon(Retailer):
|
|
65
71
|
_client: audible.AsyncClient = None
|
66
72
|
|
67
73
|
async def set_auth(self):
|
68
|
-
if not os.path.exists(
|
74
|
+
if not os.path.exists(AUTH_PATH):
|
69
75
|
auth = audible.Authenticator.from_login_external(
|
70
76
|
locale=Config.locale,
|
71
77
|
login_url_callback=login_url_callback
|
72
78
|
)
|
73
79
|
|
74
80
|
# Save credentials to file
|
75
|
-
auth.to_file(
|
81
|
+
auth.to_file(AUTH_PATH)
|
76
82
|
|
77
|
-
self._auth = audible.Authenticator.from_file(
|
83
|
+
self._auth = audible.Authenticator.from_file(AUTH_PATH)
|
78
84
|
self._client = audible.AsyncClient(auth=self._auth)
|
79
85
|
|
@@ -1,10 +1,9 @@
|
|
1
1
|
import asyncio
|
2
2
|
import math
|
3
|
-
import readline # type: ignore
|
4
3
|
|
5
4
|
from tbr_deal_finder.config import Config
|
6
5
|
from tbr_deal_finder.retailer.amazon import Amazon
|
7
|
-
from tbr_deal_finder.book import Book, BookFormat
|
6
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_title
|
8
7
|
|
9
8
|
|
10
9
|
class Audible(Amazon):
|
@@ -37,7 +36,7 @@ class Audible(Amazon):
|
|
37
36
|
)
|
38
37
|
|
39
38
|
for product in match.get("products", []):
|
40
|
-
if product["title"] != title:
|
39
|
+
if get_normalized_title(product["title"]) != title:
|
41
40
|
continue
|
42
41
|
try:
|
43
42
|
target.list_price = product["price"]["list_price"]["base"]
|
@@ -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")
|
@@ -88,17 +90,17 @@ class Chirp(Retailer):
|
|
88
90
|
) -> Book:
|
89
91
|
title = target.title
|
90
92
|
async with semaphore:
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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()
|
102
104
|
|
103
105
|
audiobooks = response_body["data"]["audiobooks"]["objects"]
|
104
106
|
if not audiobooks:
|
@@ -0,0 +1,153 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import readline # type: ignore
|
4
|
+
|
5
|
+
from tbr_deal_finder.config import Config
|
6
|
+
from tbr_deal_finder.retailer.amazon import Amazon, AUTH_PATH
|
7
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_title, get_normalized_authors, is_matching_authors
|
8
|
+
|
9
|
+
|
10
|
+
class Kindle(Amazon):
|
11
|
+
|
12
|
+
def __init__(self):
|
13
|
+
self._headers = {}
|
14
|
+
|
15
|
+
@property
|
16
|
+
def name(self) -> str:
|
17
|
+
return "Kindle"
|
18
|
+
|
19
|
+
@property
|
20
|
+
def format(self) -> BookFormat:
|
21
|
+
return BookFormat.EBOOK
|
22
|
+
|
23
|
+
def _get_base_url(self) -> str:
|
24
|
+
return f"https://www.amazon.{self._auth.locale.domain}"
|
25
|
+
|
26
|
+
def _get_read_base_url(self) -> str:
|
27
|
+
return f"https://read.amazon.{self._auth.locale.domain}"
|
28
|
+
|
29
|
+
async def set_auth(self):
|
30
|
+
await super().set_auth()
|
31
|
+
|
32
|
+
with open(AUTH_PATH, "r") as f:
|
33
|
+
auth_info = json.load(f)
|
34
|
+
|
35
|
+
cookies = auth_info["website_cookies"]
|
36
|
+
cookies["x-access-token"] = auth_info["access_token"]
|
37
|
+
|
38
|
+
self._headers = {
|
39
|
+
"User-Agent": "Mozilla/5.0 (Linux; Android 10; Kindle) AppleWebKit/537.3",
|
40
|
+
"Accept": "application/json, */*",
|
41
|
+
"Cookie": "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
42
|
+
}
|
43
|
+
|
44
|
+
|
45
|
+
async def get_book_asin(
|
46
|
+
self,
|
47
|
+
target: Book,
|
48
|
+
semaphore: asyncio.Semaphore
|
49
|
+
) -> Book:
|
50
|
+
title = target.title
|
51
|
+
async with semaphore:
|
52
|
+
match = await self._client.get(
|
53
|
+
f"{self._get_base_url()}/kindle-dbs/kws?userCode=AndroidKin&deviceType=A3VNNDO1I14V03&node=2671536011&excludedNodes=&page=1&size=20&autoSpellCheck=1&rank=r",
|
54
|
+
query=title,
|
55
|
+
)
|
56
|
+
|
57
|
+
for product in match.get("items", []):
|
58
|
+
normalized_authors = get_normalized_authors(product["authors"])
|
59
|
+
if (
|
60
|
+
get_normalized_title(product["title"]) != title
|
61
|
+
or not is_matching_authors(target.normalized_authors, normalized_authors)
|
62
|
+
):
|
63
|
+
continue
|
64
|
+
try:
|
65
|
+
target.ebook_asin = product["asin"]
|
66
|
+
break
|
67
|
+
except KeyError:
|
68
|
+
continue
|
69
|
+
|
70
|
+
return target
|
71
|
+
|
72
|
+
async def get_book(
|
73
|
+
self,
|
74
|
+
target: Book,
|
75
|
+
semaphore: asyncio.Semaphore
|
76
|
+
) -> Book:
|
77
|
+
target.exists = False
|
78
|
+
|
79
|
+
if not target.ebook_asin:
|
80
|
+
return target
|
81
|
+
|
82
|
+
asin = target.ebook_asin
|
83
|
+
async with semaphore:
|
84
|
+
match = await self._client.get(
|
85
|
+
f"{self._get_base_url()}/api/bifrost/offers/batch/v1/{asin}?ref_=KindleDeepLinkOffers",
|
86
|
+
headers={"x-client-id": "kindle-android-deeplink"},
|
87
|
+
)
|
88
|
+
products = match.get("resources", [])
|
89
|
+
if not products:
|
90
|
+
return target
|
91
|
+
|
92
|
+
actions = products[0].get("personalizedActionOutput", {}).get("personalizedActions", [])
|
93
|
+
if not actions:
|
94
|
+
return target
|
95
|
+
|
96
|
+
for action in actions:
|
97
|
+
if "printListPrice" in action["offer"]:
|
98
|
+
target.list_price = action["offer"]["printListPrice"]["value"]
|
99
|
+
target.current_price = action["offer"]["digitalPrice"]["value"]
|
100
|
+
target.exists = True
|
101
|
+
break
|
102
|
+
|
103
|
+
return target
|
104
|
+
|
105
|
+
async def get_wishlist(self, config: Config) -> list[Book]:
|
106
|
+
"""Not currently supported
|
107
|
+
|
108
|
+
Getting this info is proving to be a nightmare
|
109
|
+
|
110
|
+
:param config:
|
111
|
+
:return:
|
112
|
+
"""
|
113
|
+
return []
|
114
|
+
|
115
|
+
async def get_library(self, config: Config) -> list[Book]:
|
116
|
+
books = []
|
117
|
+
pagination_token = 0
|
118
|
+
url = f"{self._get_read_base_url()}/kindle-library/search"
|
119
|
+
|
120
|
+
while True:
|
121
|
+
optional_params = {}
|
122
|
+
if pagination_token:
|
123
|
+
optional_params["paginationToken"] = pagination_token
|
124
|
+
|
125
|
+
response = await self._client.get(
|
126
|
+
url,
|
127
|
+
headers=self._headers,
|
128
|
+
query="",
|
129
|
+
libraryType="BOOKS",
|
130
|
+
sortType="recency",
|
131
|
+
resourceType="EBOOK",
|
132
|
+
querySize=50,
|
133
|
+
**optional_params
|
134
|
+
)
|
135
|
+
|
136
|
+
for book in response["itemsList"]:
|
137
|
+
books.append(
|
138
|
+
Book(
|
139
|
+
retailer=self.name,
|
140
|
+
title = book["title"],
|
141
|
+
authors = book["authors"][0],
|
142
|
+
format=self.format,
|
143
|
+
timepoint=config.run_time,
|
144
|
+
ebook_asin=book["asin"],
|
145
|
+
)
|
146
|
+
)
|
147
|
+
|
148
|
+
if "paginationToken" in response:
|
149
|
+
pagination_token = int(response["paginationToken"])
|
150
|
+
else:
|
151
|
+
break
|
152
|
+
|
153
|
+
return books
|
@@ -4,17 +4,16 @@ import os
|
|
4
4
|
import urllib.parse
|
5
5
|
from datetime import datetime, timedelta
|
6
6
|
|
7
|
-
import aiohttp
|
8
7
|
import click
|
9
8
|
|
10
9
|
from tbr_deal_finder import TBR_DEALS_PATH
|
11
10
|
from tbr_deal_finder.config import Config
|
12
|
-
from tbr_deal_finder.retailer.models import Retailer
|
13
|
-
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
14
|
-
from tbr_deal_finder.utils import currency_to_float
|
11
|
+
from tbr_deal_finder.retailer.models import AioHttpSession, Retailer
|
12
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors, get_normalized_title
|
13
|
+
from tbr_deal_finder.utils import currency_to_float, echo_err
|
15
14
|
|
16
15
|
|
17
|
-
class LibroFM(Retailer):
|
16
|
+
class LibroFM(AioHttpSession, Retailer):
|
18
17
|
BASE_URL = "https://libro.fm"
|
19
18
|
USER_AGENT = "okhttp/3.14.9"
|
20
19
|
USER_AGENT_DOWNLOAD = (
|
@@ -27,6 +26,8 @@ class LibroFM(Retailer):
|
|
27
26
|
)
|
28
27
|
|
29
28
|
def __init__(self):
|
29
|
+
super().__init__()
|
30
|
+
|
30
31
|
self.auth_token = None
|
31
32
|
|
32
33
|
@property
|
@@ -44,17 +45,17 @@ class LibroFM(Retailer):
|
|
44
45
|
if self.auth_token:
|
45
46
|
headers["authorization"] = f"Bearer {self.auth_token}"
|
46
47
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
48
|
+
session = await self._get_session()
|
49
|
+
response = await session.request(
|
50
|
+
request_type.upper(),
|
51
|
+
url,
|
52
|
+
headers=headers,
|
53
|
+
**kwargs
|
54
|
+
)
|
55
|
+
if response.ok:
|
56
|
+
return await response.json()
|
57
|
+
else:
|
58
|
+
return {}
|
58
59
|
|
59
60
|
async def set_auth(self):
|
60
61
|
auth_path = TBR_DEALS_PATH.joinpath("libro_fm.json")
|
@@ -76,6 +77,10 @@ class LibroFM(Retailer):
|
|
76
77
|
"password": click.prompt("Libro FM Password", hide_input=True),
|
77
78
|
}
|
78
79
|
)
|
80
|
+
if "access_token" not in response:
|
81
|
+
echo_err("Login failed. Try again.")
|
82
|
+
await self.set_auth()
|
83
|
+
|
79
84
|
self.auth_token = response["access_token"]
|
80
85
|
with open(auth_path, "w") as f:
|
81
86
|
json.dump(response, f)
|
@@ -100,7 +105,7 @@ class LibroFM(Retailer):
|
|
100
105
|
normalized_authors = get_normalized_authors(b["authors"])
|
101
106
|
|
102
107
|
if (
|
103
|
-
title == b["title"]
|
108
|
+
title == get_normalized_title(b["title"])
|
104
109
|
and is_matching_authors(book.normalized_authors, normalized_authors)
|
105
110
|
):
|
106
111
|
book.audiobook_isbn = b["isbn"]
|
@@ -1,6 +1,8 @@
|
|
1
1
|
import abc
|
2
2
|
import asyncio
|
3
3
|
|
4
|
+
import aiohttp
|
5
|
+
|
4
6
|
from tbr_deal_finder.book import Book, BookFormat
|
5
7
|
from tbr_deal_finder.config import Config
|
6
8
|
|
@@ -52,3 +54,37 @@ class Retailer(abc.ABC):
|
|
52
54
|
async def get_library(self, config: Config) -> list[Book]:
|
53
55
|
raise NotImplementedError
|
54
56
|
|
57
|
+
|
58
|
+
class AioHttpSession:
|
59
|
+
|
60
|
+
def __init__(self):
|
61
|
+
self._session = None
|
62
|
+
|
63
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
64
|
+
"""Get or create the session."""
|
65
|
+
if self._session is None or self._session.closed:
|
66
|
+
self._session = aiohttp.ClientSession()
|
67
|
+
return self._session
|
68
|
+
|
69
|
+
async def close(self):
|
70
|
+
"""Close the session when done."""
|
71
|
+
if self._session and not self._session.closed:
|
72
|
+
await self._session.close()
|
73
|
+
|
74
|
+
async def __aenter__(self):
|
75
|
+
"""Support async context manager."""
|
76
|
+
return self
|
77
|
+
|
78
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
79
|
+
"""Cleanup on context manager exit."""
|
80
|
+
await self.close()
|
81
|
+
|
82
|
+
def __del__(self):
|
83
|
+
"""Attempt to close session on garbage collection."""
|
84
|
+
if self._session and not self._session.closed:
|
85
|
+
try:
|
86
|
+
asyncio.create_task(self._session.close())
|
87
|
+
except RuntimeError:
|
88
|
+
# Event loop might be closed
|
89
|
+
pass
|
90
|
+
|
tbr_deal_finder/retailer_deal.py
CHANGED
@@ -8,7 +8,7 @@ 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.tracked_books import get_tbr_books
|
11
|
+
from tbr_deal_finder.tracked_books import get_tbr_books, get_unknown_books, set_unknown_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
|
@@ -55,7 +55,12 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
55
55
|
db_conn.unregister("_df")
|
56
56
|
|
57
57
|
|
58
|
-
async def _get_books(
|
58
|
+
async def _get_books(
|
59
|
+
config,
|
60
|
+
retailer: Retailer,
|
61
|
+
books: list[Book],
|
62
|
+
ignored_deal_ids: set[str],
|
63
|
+
) -> tuple[list[Book], list[Book]]:
|
59
64
|
"""Get Books with limited concurrency.
|
60
65
|
|
61
66
|
- Creates semaphore to limit concurrent requests.
|
@@ -72,7 +77,7 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
72
77
|
"""
|
73
78
|
semaphore = asyncio.Semaphore(10)
|
74
79
|
response = []
|
75
|
-
|
80
|
+
unknown_books = []
|
76
81
|
books = [copy.deepcopy(book) for book in books]
|
77
82
|
for book in books:
|
78
83
|
book.retailer = retailer.name
|
@@ -81,19 +86,20 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
81
86
|
tasks = [
|
82
87
|
retailer.get_book(book, semaphore)
|
83
88
|
for book in books
|
89
|
+
if book.deal_id not in ignored_deal_ids
|
84
90
|
]
|
85
91
|
results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
|
86
92
|
for book in results:
|
87
93
|
if book.exists:
|
88
94
|
response.append(book)
|
89
95
|
elif not book.exists:
|
90
|
-
|
96
|
+
unknown_books.append(book)
|
91
97
|
|
92
98
|
click.echo()
|
93
|
-
for book in
|
99
|
+
for book in unknown_books:
|
94
100
|
echo_info(f"{book.title} by {book.authors} not found")
|
95
101
|
|
96
|
-
return response
|
102
|
+
return response, unknown_books
|
97
103
|
|
98
104
|
|
99
105
|
def _apply_proper_list_prices(books: list[Book]):
|
@@ -169,7 +175,10 @@ async def get_latest_deals(config: Config):
|
|
169
175
|
"""
|
170
176
|
|
171
177
|
books: list[Book] = []
|
178
|
+
unknown_books: list[Book] = []
|
172
179
|
tbr_books = await get_tbr_books(config)
|
180
|
+
ignore_books: list[Book] = get_unknown_books(config)
|
181
|
+
ignored_deal_ids: set[str] = {book.deal_id for book in ignore_books}
|
173
182
|
|
174
183
|
for retailer_str in config.tracked_retailers:
|
175
184
|
retailer = RETAILER_MAP[retailer_str]()
|
@@ -182,7 +191,14 @@ async def get_latest_deals(config: Config):
|
|
182
191
|
|
183
192
|
echo_info(f"Getting deals from {retailer.name}")
|
184
193
|
click.echo("\n---------------")
|
185
|
-
|
194
|
+
retailer_books, u_books = await _get_books(
|
195
|
+
config,
|
196
|
+
retailer,
|
197
|
+
relevant_tbr_books,
|
198
|
+
ignored_deal_ids
|
199
|
+
)
|
200
|
+
books.extend(retailer_books)
|
201
|
+
unknown_books.extend(u_books)
|
186
202
|
click.echo("---------------\n")
|
187
203
|
|
188
204
|
_apply_proper_list_prices(books)
|
@@ -194,3 +210,4 @@ async def get_latest_deals(config: Config):
|
|
194
210
|
]
|
195
211
|
|
196
212
|
update_retailer_deal_table(config, books)
|
213
|
+
set_unknown_books(config, unknown_books)
|
tbr_deal_finder/tracked_books.py
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
import asyncio
|
2
2
|
import copy
|
3
3
|
import csv
|
4
|
+
import functools
|
4
5
|
from collections import defaultdict
|
6
|
+
from datetime import datetime, timedelta
|
5
7
|
from typing import Callable, Awaitable, Optional
|
6
8
|
|
7
9
|
import pandas as pd
|
@@ -9,10 +11,10 @@ from tqdm.asyncio import tqdm_asyncio
|
|
9
11
|
|
10
12
|
from tbr_deal_finder.book import Book, BookFormat, get_title_id
|
11
13
|
from tbr_deal_finder.owned_books import get_owned_books
|
12
|
-
from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM
|
14
|
+
from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM, Kindle
|
13
15
|
from tbr_deal_finder.config import Config
|
14
16
|
from tbr_deal_finder.retailer.models import Retailer
|
15
|
-
from tbr_deal_finder.utils import execute_query, get_duckdb_conn
|
17
|
+
from tbr_deal_finder.utils import execute_query, get_duckdb_conn, get_query_by_name
|
16
18
|
|
17
19
|
|
18
20
|
def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
@@ -194,10 +196,6 @@ async def _maybe_set_audiobook_list_price(config: Config, new_tbr_books: list[Bo
|
|
194
196
|
|
195
197
|
async def _maybe_set_audiobook_isbn(config: Config, new_tbr_books: list[Book]):
|
196
198
|
"""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
199
|
"""
|
202
200
|
if "Libro.FM" not in config.tracked_retailers:
|
203
201
|
return
|
@@ -218,6 +216,88 @@ async def _maybe_set_audiobook_isbn(config: Config, new_tbr_books: list[Book]):
|
|
218
216
|
)
|
219
217
|
|
220
218
|
|
219
|
+
@functools.cache
|
220
|
+
def unknown_books_requires_sync() -> bool:
|
221
|
+
db_conn = get_duckdb_conn()
|
222
|
+
results = execute_query(
|
223
|
+
db_conn,
|
224
|
+
get_query_by_name("latest_unknown_book_sync.sql")
|
225
|
+
)
|
226
|
+
if not results:
|
227
|
+
return True
|
228
|
+
|
229
|
+
sync_last_ran = results[0]["timepoint"]
|
230
|
+
return datetime.now() - timedelta(days=7) > sync_last_ran
|
231
|
+
|
232
|
+
|
233
|
+
def clear_unknown_books():
|
234
|
+
db_conn = get_duckdb_conn()
|
235
|
+
db_conn.execute(
|
236
|
+
"DELETE FROM unknown_book"
|
237
|
+
)
|
238
|
+
db_conn.execute(
|
239
|
+
"DELETE FROM unknown_book_run_history"
|
240
|
+
)
|
241
|
+
|
242
|
+
|
243
|
+
def set_unknown_books(config: Config, unknown_books: list[Book]):
|
244
|
+
if not unknown_books_requires_sync():
|
245
|
+
return
|
246
|
+
|
247
|
+
db_conn = get_duckdb_conn()
|
248
|
+
db_conn.execute(
|
249
|
+
"INSERT INTO unknown_book_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
|
250
|
+
[config.run_time, True, ""]
|
251
|
+
)
|
252
|
+
|
253
|
+
db_conn.execute(
|
254
|
+
"DELETE FROM unknown_book"
|
255
|
+
)
|
256
|
+
if not unknown_books:
|
257
|
+
return
|
258
|
+
|
259
|
+
df = pd.DataFrame([book.unknown_book_dict() for book in unknown_books])
|
260
|
+
db_conn = get_duckdb_conn()
|
261
|
+
db_conn.register("_df", df)
|
262
|
+
db_conn.execute("INSERT INTO unknown_book SELECT * FROM _df;")
|
263
|
+
db_conn.unregister("_df")
|
264
|
+
|
265
|
+
|
266
|
+
def get_unknown_books(config: Config) -> list[Book]:
|
267
|
+
if unknown_books_requires_sync():
|
268
|
+
return []
|
269
|
+
|
270
|
+
db_conn = get_duckdb_conn()
|
271
|
+
unknown_book_data = execute_query(
|
272
|
+
db_conn,
|
273
|
+
"SELECT * EXCLUDE(book_id) FROM unknown_book"
|
274
|
+
)
|
275
|
+
|
276
|
+
return [Book(timepoint=config.run_time, **b) for b in unknown_book_data]
|
277
|
+
|
278
|
+
|
279
|
+
async def _maybe_set_ebook_asin(config: Config, new_tbr_books: list[Book]):
|
280
|
+
"""To get the price from kindle for a book, you need its asin
|
281
|
+
"""
|
282
|
+
if "Kindle" not in config.tracked_retailers:
|
283
|
+
return
|
284
|
+
|
285
|
+
kindle = Kindle()
|
286
|
+
await kindle.set_auth()
|
287
|
+
|
288
|
+
relevant_tbr_books = [
|
289
|
+
book
|
290
|
+
for book in new_tbr_books
|
291
|
+
if book.format in [BookFormat.EBOOK, BookFormat.NA]
|
292
|
+
]
|
293
|
+
|
294
|
+
await _set_tbr_book_attr(
|
295
|
+
relevant_tbr_books,
|
296
|
+
"ebook_asin",
|
297
|
+
kindle.get_book_asin,
|
298
|
+
)
|
299
|
+
|
300
|
+
|
221
301
|
def get_book_authors(book: dict) -> str:
|
222
302
|
if authors := book.get('Authors'):
|
223
303
|
return authors
|
@@ -301,6 +381,7 @@ async def sync_tbr_books(config: Config):
|
|
301
381
|
|
302
382
|
await _maybe_set_audiobook_list_price(config, new_tbr_books)
|
303
383
|
await _maybe_set_audiobook_isbn(config, new_tbr_books)
|
384
|
+
await _maybe_set_ebook_asin(config, new_tbr_books)
|
304
385
|
|
305
386
|
df = pd.DataFrame([book.tbr_dict() for book in new_tbr_books])
|
306
387
|
db_conn.register("_df", df)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1
|
3
|
+
Version: 0.2.1
|
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
|
@@ -23,7 +23,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
23
23
|
## Features
|
24
24
|
- Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
25
25
|
- Supports multiple of the library exports above
|
26
|
-
- Tracks deals on the wishlist of all your configured retailers like audible
|
26
|
+
- Tracks deals on the wishlist of all your configured retailers like audible (excluding kindle)
|
27
27
|
- Supports multiple locales and currencies
|
28
28
|
- Find the latest and active deals from supported sellers
|
29
29
|
- Simple CLI interface for setup and usage
|
@@ -37,6 +37,9 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
37
37
|
* Chirp
|
38
38
|
* Libro.fm
|
39
39
|
|
40
|
+
### EBooks
|
41
|
+
* Kindle
|
42
|
+
|
40
43
|
### Locales
|
41
44
|
* US
|
42
45
|
* CA
|
@@ -0,0 +1,25 @@
|
|
1
|
+
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
+
tbr_deal_finder/book.py,sha256=mJzuDgyd5UQqlfubj8e9uhmbUxliYvD1y-oQkfenIAM,6414
|
3
|
+
tbr_deal_finder/cli.py,sha256=YnfYyjoeQ0pefPdT-fcz19NQWlEWTHldAR52owoAxd8,7493
|
4
|
+
tbr_deal_finder/config.py,sha256=Bpr9C9NClsHrFJuykcITrtRTr2vxAzbcZrMQ85neQ-Y,3978
|
5
|
+
tbr_deal_finder/migrations.py,sha256=fO7r2JbWb6YG0CsPqauakwvbKaEFPxqX1PP8c8N03Wc,4951
|
6
|
+
tbr_deal_finder/owned_books.py,sha256=Cf1VeiSg7XBi_TXptJfy5sO1mEgMMQWbJ_P6SzAx0nQ,516
|
7
|
+
tbr_deal_finder/retailer_deal.py,sha256=_0ZxsTB3frWHKFmfIWZOdsXGe5zOylNh04oamuWd45c,6955
|
8
|
+
tbr_deal_finder/tracked_books.py,sha256=1zKv1aRqP9AnyZlzfyAWWUNamrFG6UhWXubZ-1HzBm4,12486
|
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/queries/latest_unknown_book_sync.sql,sha256=d4ewoYP5otnCj0_TqsXCCLI8BEmHzqTyJrGxTvl2l-I,108
|
14
|
+
tbr_deal_finder/retailer/__init__.py,sha256=OD6jUYV8LaURxqHnZq-aiFi7OdWG6qWznRlF_g246lo,316
|
15
|
+
tbr_deal_finder/retailer/amazon.py,sha256=sUjNkKEBEOeT_mYPDyi4XKvijM8kM8ZT4hKAe7-j7yI,2468
|
16
|
+
tbr_deal_finder/retailer/audible.py,sha256=qwXDKc1W8vGGhqvU2YI7hNfD1rHz2yL-8foXstxb8t8,3991
|
17
|
+
tbr_deal_finder/retailer/chirp.py,sha256=f_O-6X9duR_gBT8UWDxDr-KQYSRFOOiYOX9Az2pCi6Y,9183
|
18
|
+
tbr_deal_finder/retailer/kindle.py,sha256=ZkOqNu43C5JbngB6lZhdK0373NH-iJKHE1fFIjGuMow,4829
|
19
|
+
tbr_deal_finder/retailer/librofm.py,sha256=TRey_38sf3VGpvXxLwhdqwYKP7Izj07c9Qs3n1pu5_Y,6494
|
20
|
+
tbr_deal_finder/retailer/models.py,sha256=xm99ngt_Ze7yyEwttddkpwL7xjy0YfcFAduV6Rsx63M,2510
|
21
|
+
tbr_deal_finder-0.2.1.dist-info/METADATA,sha256=QKG3ak5XtMq_TLYHF10BFDak_tycxBCJaeNce6qKVLg,4381
|
22
|
+
tbr_deal_finder-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
23
|
+
tbr_deal_finder-0.2.1.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
24
|
+
tbr_deal_finder-0.2.1.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
25
|
+
tbr_deal_finder-0.2.1.dist-info/RECORD,,
|
@@ -1,23 +0,0 @@
|
|
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,,
|
File without changes
|
File without changes
|
File without changes
|