tbr-deal-finder 0.1.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/__init__.py +9 -0
- tbr_deal_finder/book.py +116 -0
- tbr_deal_finder/config.py +104 -0
- tbr_deal_finder/library_exports.py +155 -0
- tbr_deal_finder/main.py +223 -0
- tbr_deal_finder/migrations.py +125 -0
- tbr_deal_finder/queries/get_active_deals.sql +4 -0
- tbr_deal_finder/queries/get_deals_found_at.sql +4 -0
- tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +5 -0
- tbr_deal_finder/retailer/__init__.py +9 -0
- tbr_deal_finder/retailer/audible.py +115 -0
- tbr_deal_finder/retailer/chirp.py +79 -0
- tbr_deal_finder/retailer/librofm.py +136 -0
- tbr_deal_finder/retailer/models.py +37 -0
- tbr_deal_finder/retailer_deal.py +184 -0
- tbr_deal_finder/utils.py +40 -0
- tbr_deal_finder-0.1.0.dist-info/METADATA +167 -0
- tbr_deal_finder-0.1.0.dist-info/RECORD +21 -0
- tbr_deal_finder-0.1.0.dist-info/WHEEL +4 -0
- tbr_deal_finder-0.1.0.dist-info/entry_points.txt +2 -0
- tbr_deal_finder-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,125 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import List, Dict
|
3
|
+
from collections import defaultdict
|
4
|
+
|
5
|
+
import click
|
6
|
+
import duckdb
|
7
|
+
|
8
|
+
from tbr_deal_finder.utils import get_duckdb_conn
|
9
|
+
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class TableMigration:
|
13
|
+
version: int
|
14
|
+
table_name: str
|
15
|
+
sql: str
|
16
|
+
|
17
|
+
|
18
|
+
_MIGRATIONS = [
|
19
|
+
TableMigration(
|
20
|
+
version=1,
|
21
|
+
table_name="retailer_deal",
|
22
|
+
sql="""
|
23
|
+
CREATE TABLE retailer_deal
|
24
|
+
(
|
25
|
+
retailer VARCHAR,
|
26
|
+
title VARCHAR,
|
27
|
+
authors VARCHAR,
|
28
|
+
list_price FLOAT,
|
29
|
+
current_price FLOAT,
|
30
|
+
timepoint TIMESTAMP_NS,
|
31
|
+
format VARCHAR,
|
32
|
+
deleted BOOLEAN,
|
33
|
+
deal_id VARCHAR
|
34
|
+
);
|
35
|
+
"""
|
36
|
+
),
|
37
|
+
TableMigration(
|
38
|
+
version=1,
|
39
|
+
table_name="latest_deal_run_history",
|
40
|
+
sql="""
|
41
|
+
CREATE TABLE latest_deal_run_history
|
42
|
+
(
|
43
|
+
timepoint TIMESTAMP_NS,
|
44
|
+
ran_successfully BOOLEAN,
|
45
|
+
details VARCHAR
|
46
|
+
);
|
47
|
+
"""
|
48
|
+
),
|
49
|
+
]
|
50
|
+
|
51
|
+
|
52
|
+
def apply_migration(migration: TableMigration, cursor: duckdb.DuckDBPyConnection) -> None:
|
53
|
+
"""Apply a single migration to the database."""
|
54
|
+
click.echo(
|
55
|
+
f"Applying migration - version: {migration.version}, table: {migration.table_name}"
|
56
|
+
)
|
57
|
+
|
58
|
+
try:
|
59
|
+
# Execute the migration SQL
|
60
|
+
cursor.execute(migration.sql)
|
61
|
+
|
62
|
+
# Update schema_versions table
|
63
|
+
cursor.execute("""
|
64
|
+
INSERT INTO schema_versions (table_name, version)
|
65
|
+
VALUES (?, ?) ON CONFLICT(table_name) DO
|
66
|
+
UPDATE SET version = EXCLUDED.version
|
67
|
+
""", (migration.table_name, migration.version))
|
68
|
+
|
69
|
+
click.echo(
|
70
|
+
f"Migration applied successfully - version: {migration.version}, table: {migration.table_name}"
|
71
|
+
)
|
72
|
+
|
73
|
+
except duckdb.Error as e:
|
74
|
+
raise RuntimeError(
|
75
|
+
f"Failed to apply migration {migration.version} to {migration.table_name}: {e}"
|
76
|
+
)
|
77
|
+
|
78
|
+
|
79
|
+
def make_migrations():
|
80
|
+
db_conn = get_duckdb_conn()
|
81
|
+
|
82
|
+
try:
|
83
|
+
# Create schema_versions table if it doesn't exist
|
84
|
+
db_conn.execute("""
|
85
|
+
CREATE TABLE IF NOT EXISTS schema_versions
|
86
|
+
(
|
87
|
+
table_name
|
88
|
+
VARCHAR
|
89
|
+
PRIMARY
|
90
|
+
KEY,
|
91
|
+
version
|
92
|
+
INTEGER
|
93
|
+
);
|
94
|
+
""")
|
95
|
+
|
96
|
+
# Group migrations by table name
|
97
|
+
table_migration_map: Dict[str, List[TableMigration]] = defaultdict(list)
|
98
|
+
for migration in _MIGRATIONS:
|
99
|
+
table_migration_map[migration.table_name].append(migration)
|
100
|
+
|
101
|
+
# Begin transaction
|
102
|
+
with db_conn:
|
103
|
+
cursor = db_conn.cursor()
|
104
|
+
|
105
|
+
for table_name, table_migrations in table_migration_map.items():
|
106
|
+
# Sort migrations by version
|
107
|
+
table_migrations.sort(key=lambda m: m.version)
|
108
|
+
|
109
|
+
# Get current version for this table
|
110
|
+
cursor.execute("""
|
111
|
+
SELECT COALESCE(MAX(version), 0)
|
112
|
+
FROM schema_versions
|
113
|
+
WHERE table_name = ?
|
114
|
+
""", (table_name,))
|
115
|
+
|
116
|
+
result = cursor.fetchone()
|
117
|
+
current_version = result[0] if result else 0
|
118
|
+
|
119
|
+
# Apply pending migrations
|
120
|
+
for migration in table_migrations:
|
121
|
+
if migration.version > current_version:
|
122
|
+
apply_migration(migration, cursor)
|
123
|
+
|
124
|
+
except duckdb.Error as e:
|
125
|
+
raise RuntimeError(f"Failed to apply migrations: {e}")
|
@@ -0,0 +1,115 @@
|
|
1
|
+
import asyncio
|
2
|
+
import os.path
|
3
|
+
from datetime import datetime
|
4
|
+
from textwrap import dedent
|
5
|
+
import readline # type: ignore
|
6
|
+
|
7
|
+
|
8
|
+
import audible
|
9
|
+
import click
|
10
|
+
from audible.login import playwright_external_login_url_callback
|
11
|
+
|
12
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
13
|
+
from tbr_deal_finder.config import Config
|
14
|
+
from tbr_deal_finder.retailer.models import Retailer
|
15
|
+
from tbr_deal_finder.book import Book, BookFormat
|
16
|
+
|
17
|
+
_AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
|
18
|
+
|
19
|
+
|
20
|
+
def login_url_callback(url: str) -> str:
|
21
|
+
"""Helper function for login with external browsers."""
|
22
|
+
try:
|
23
|
+
return playwright_external_login_url_callback(url)
|
24
|
+
except ImportError:
|
25
|
+
pass
|
26
|
+
|
27
|
+
message = f"""\
|
28
|
+
Please copy the following url and insert it into a web browser of your choice to log into Amazon.
|
29
|
+
Note: your browser will show you an error page (Page not found). This is expected.
|
30
|
+
|
31
|
+
{url}
|
32
|
+
|
33
|
+
Once you have logged in, please insert the copied url.
|
34
|
+
"""
|
35
|
+
click.echo(dedent(message))
|
36
|
+
return input()
|
37
|
+
|
38
|
+
|
39
|
+
class Audible(Retailer):
|
40
|
+
_auth: audible.Authenticator = None
|
41
|
+
_client: audible.AsyncClient = None
|
42
|
+
|
43
|
+
@property
|
44
|
+
def name(self) -> str:
|
45
|
+
return "Audible"
|
46
|
+
|
47
|
+
async def get_book(
|
48
|
+
self,
|
49
|
+
target: Book,
|
50
|
+
runtime: datetime,
|
51
|
+
semaphore: asyncio.Semaphore
|
52
|
+
) -> Book:
|
53
|
+
title = target.title
|
54
|
+
authors = target.authors
|
55
|
+
|
56
|
+
async with semaphore:
|
57
|
+
match = await self._client.get(
|
58
|
+
"1.0/catalog/products",
|
59
|
+
num_results=50,
|
60
|
+
author=authors,
|
61
|
+
title=title,
|
62
|
+
response_groups=[
|
63
|
+
"contributors, media, price, product_attrs, product_desc, product_extended_attrs, product_plan_details, product_plans"
|
64
|
+
]
|
65
|
+
)
|
66
|
+
|
67
|
+
if not match["products"]:
|
68
|
+
return Book(
|
69
|
+
retailer=self.name,
|
70
|
+
title=title,
|
71
|
+
authors=authors,
|
72
|
+
list_price=0,
|
73
|
+
current_price=0,
|
74
|
+
timepoint=runtime,
|
75
|
+
format=BookFormat.AUDIOBOOK,
|
76
|
+
exists=False,
|
77
|
+
)
|
78
|
+
|
79
|
+
for product in match["products"]:
|
80
|
+
if product["title"] != title:
|
81
|
+
continue
|
82
|
+
|
83
|
+
return Book(
|
84
|
+
retailer=self.name,
|
85
|
+
title=title,
|
86
|
+
authors=authors,
|
87
|
+
list_price=product["price"]["list_price"]["base"],
|
88
|
+
current_price=product["price"]["lowest_price"]["base"],
|
89
|
+
timepoint=runtime,
|
90
|
+
format=BookFormat.AUDIOBOOK
|
91
|
+
)
|
92
|
+
|
93
|
+
return Book(
|
94
|
+
retailer=self.name,
|
95
|
+
title=title,
|
96
|
+
authors=authors,
|
97
|
+
list_price=0,
|
98
|
+
current_price=0,
|
99
|
+
timepoint=runtime,
|
100
|
+
format=BookFormat.AUDIOBOOK,
|
101
|
+
exists=False,
|
102
|
+
)
|
103
|
+
|
104
|
+
async def set_auth(self):
|
105
|
+
if not os.path.exists(_AUTH_PATH):
|
106
|
+
auth = audible.Authenticator.from_login_external(
|
107
|
+
locale=Config.locale,
|
108
|
+
login_url_callback=login_url_callback
|
109
|
+
)
|
110
|
+
|
111
|
+
# Save credentials to file
|
112
|
+
auth.to_file(_AUTH_PATH)
|
113
|
+
|
114
|
+
self._auth = audible.Authenticator.from_file(_AUTH_PATH)
|
115
|
+
self._client = audible.AsyncClient(auth=self._auth)
|
@@ -0,0 +1,79 @@
|
|
1
|
+
import asyncio
|
2
|
+
from datetime import datetime
|
3
|
+
|
4
|
+
import aiohttp
|
5
|
+
|
6
|
+
from tbr_deal_finder.retailer.models import Retailer
|
7
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
8
|
+
from tbr_deal_finder.utils import currency_to_float
|
9
|
+
|
10
|
+
|
11
|
+
class Chirp(Retailer):
|
12
|
+
# Static because url for other locales just redirects to .com
|
13
|
+
_url: str = "https://www.chirpbooks.com/api/graphql"
|
14
|
+
|
15
|
+
@property
|
16
|
+
def name(self) -> str:
|
17
|
+
return "Chirp"
|
18
|
+
|
19
|
+
async def get_book(
|
20
|
+
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
21
|
+
) -> Book:
|
22
|
+
title = target.title
|
23
|
+
authors = target.authors
|
24
|
+
|
25
|
+
async with aiohttp.ClientSession() as http_client:
|
26
|
+
response = await http_client.request(
|
27
|
+
"POST",
|
28
|
+
self._url,
|
29
|
+
json={
|
30
|
+
"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}}}}}",
|
31
|
+
"variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
|
32
|
+
"operationName": "AudiobookSearch"
|
33
|
+
}
|
34
|
+
)
|
35
|
+
response_body = await response.json()
|
36
|
+
audiobooks = response_body["data"]["audiobooks"]["objects"]
|
37
|
+
if not audiobooks:
|
38
|
+
return Book(
|
39
|
+
retailer=self.name,
|
40
|
+
title=title,
|
41
|
+
authors=authors,
|
42
|
+
list_price=0,
|
43
|
+
current_price=0,
|
44
|
+
timepoint=runtime,
|
45
|
+
format=BookFormat.AUDIOBOOK,
|
46
|
+
exists=False,
|
47
|
+
)
|
48
|
+
|
49
|
+
for book in audiobooks:
|
50
|
+
if not book["currentProduct"]:
|
51
|
+
continue
|
52
|
+
|
53
|
+
if (
|
54
|
+
book["displayTitle"] == title
|
55
|
+
and get_normalized_authors(book["displayAuthors"]) == target.normalized_authors
|
56
|
+
):
|
57
|
+
return Book(
|
58
|
+
retailer=self.name,
|
59
|
+
title=title,
|
60
|
+
authors=authors,
|
61
|
+
list_price=currency_to_float(book["currentProduct"]["listingPrice"]),
|
62
|
+
current_price=currency_to_float(book["currentProduct"]["discountPrice"]),
|
63
|
+
timepoint=runtime,
|
64
|
+
format=BookFormat.AUDIOBOOK,
|
65
|
+
)
|
66
|
+
|
67
|
+
return Book(
|
68
|
+
retailer=self.name,
|
69
|
+
title=title,
|
70
|
+
authors=target.authors,
|
71
|
+
list_price=0,
|
72
|
+
current_price=0,
|
73
|
+
timepoint=runtime,
|
74
|
+
format=BookFormat.AUDIOBOOK,
|
75
|
+
exists=False,
|
76
|
+
)
|
77
|
+
|
78
|
+
async def set_auth(self):
|
79
|
+
return
|
@@ -0,0 +1,136 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
import urllib.parse
|
5
|
+
from datetime import datetime, timedelta
|
6
|
+
from typing import Union
|
7
|
+
|
8
|
+
import aiohttp
|
9
|
+
import click
|
10
|
+
|
11
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
12
|
+
from tbr_deal_finder.retailer.models import Retailer
|
13
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
14
|
+
from tbr_deal_finder.utils import currency_to_float
|
15
|
+
|
16
|
+
|
17
|
+
class LibroFM(Retailer):
|
18
|
+
BASE_URL = "https://libro.fm"
|
19
|
+
USER_AGENT = "okhttp/3.14.9"
|
20
|
+
USER_AGENT_DOWNLOAD = (
|
21
|
+
"AndroidDownloadManager/11 (Linux; U; Android 11; "
|
22
|
+
"Android SDK built for x86_64 Build/RSR1.210722.013.A2)"
|
23
|
+
)
|
24
|
+
CLIENT_VERSION = (
|
25
|
+
"Android: Libro.fm 7.6.1 Build: 194 Device: Android SDK built for x86_64 "
|
26
|
+
"(unknown sdk_phone_x86_64) AndroidOS: 11 SDK: 30"
|
27
|
+
)
|
28
|
+
|
29
|
+
def __init__(self):
|
30
|
+
self.auth_token = None
|
31
|
+
|
32
|
+
@property
|
33
|
+
def name(self) -> str:
|
34
|
+
return "Libro.FM"
|
35
|
+
|
36
|
+
async def make_request(self, url_path: str, request_type: str, **kwargs) -> dict:
|
37
|
+
url = urllib.parse.urljoin(self.BASE_URL, url_path)
|
38
|
+
headers = kwargs.pop("headers", {})
|
39
|
+
headers["User-Agent"] = self.USER_AGENT
|
40
|
+
headers["authorization"] = f"Bearer {self.auth_token}"
|
41
|
+
|
42
|
+
async with aiohttp.ClientSession() as http_client:
|
43
|
+
response = await http_client.request(
|
44
|
+
request_type.upper(),
|
45
|
+
url,
|
46
|
+
headers=headers,
|
47
|
+
**kwargs
|
48
|
+
)
|
49
|
+
if response.ok:
|
50
|
+
return await response.json()
|
51
|
+
else:
|
52
|
+
return {}
|
53
|
+
|
54
|
+
async def set_auth(self):
|
55
|
+
auth_path = TBR_DEALS_PATH.joinpath("libro_fm.json")
|
56
|
+
if os.path.exists(auth_path):
|
57
|
+
with open(auth_path, "r") as f:
|
58
|
+
auth_info = json.load(f)
|
59
|
+
token_created_at = datetime.fromtimestamp(auth_info["created_at"])
|
60
|
+
max_token_age = datetime.now() - timedelta(days=5)
|
61
|
+
if token_created_at > max_token_age:
|
62
|
+
self.auth_token = auth_info["access_token"]
|
63
|
+
return
|
64
|
+
|
65
|
+
response = await self.make_request(
|
66
|
+
"/oauth/token",
|
67
|
+
"POST",
|
68
|
+
json={
|
69
|
+
"grant_type": "password",
|
70
|
+
"username": click.prompt("Libro FM Username"),
|
71
|
+
"password": click.prompt("Libro FM Password", hide_input=True),
|
72
|
+
}
|
73
|
+
)
|
74
|
+
self.auth_token = response
|
75
|
+
with open(auth_path, "w") as f:
|
76
|
+
json.dump(response, f)
|
77
|
+
|
78
|
+
async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Union[str, None]:
|
79
|
+
title = book.title
|
80
|
+
response = await self.make_request(
|
81
|
+
f"api/v10/explore/search",
|
82
|
+
"GET",
|
83
|
+
params={
|
84
|
+
"q": title,
|
85
|
+
"searchby": "titles",
|
86
|
+
"sortby": "relevance#results",
|
87
|
+
},
|
88
|
+
)
|
89
|
+
|
90
|
+
for b in response["audiobook_collection"]["audiobooks"]:
|
91
|
+
if title == b["title"] and book.normalized_authors == get_normalized_authors(b["authors"]):
|
92
|
+
book.audiobook_isbn = b["isbn"]
|
93
|
+
return book.audiobook_isbn
|
94
|
+
|
95
|
+
return None
|
96
|
+
|
97
|
+
async def get_book(
|
98
|
+
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
99
|
+
) -> Book:
|
100
|
+
if not target.audiobook_isbn:
|
101
|
+
return Book(
|
102
|
+
retailer=self.name,
|
103
|
+
title=target.title,
|
104
|
+
authors=target.authors,
|
105
|
+
list_price=0,
|
106
|
+
current_price=0,
|
107
|
+
timepoint=runtime,
|
108
|
+
format=BookFormat.AUDIOBOOK,
|
109
|
+
exists=False,
|
110
|
+
)
|
111
|
+
response = await self.make_request(
|
112
|
+
f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
|
113
|
+
"GET"
|
114
|
+
)
|
115
|
+
|
116
|
+
if response:
|
117
|
+
return Book(
|
118
|
+
retailer=self.name,
|
119
|
+
title=target.title,
|
120
|
+
authors=target.authors,
|
121
|
+
list_price=0,
|
122
|
+
current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
|
123
|
+
timepoint=runtime,
|
124
|
+
format=BookFormat.AUDIOBOOK,
|
125
|
+
)
|
126
|
+
|
127
|
+
return Book(
|
128
|
+
retailer=self.name,
|
129
|
+
title=target.title,
|
130
|
+
authors=target.authors,
|
131
|
+
list_price=0,
|
132
|
+
current_price=0,
|
133
|
+
timepoint=runtime,
|
134
|
+
format=BookFormat.AUDIOBOOK,
|
135
|
+
exists=False,
|
136
|
+
)
|
@@ -0,0 +1,37 @@
|
|
1
|
+
import abc
|
2
|
+
import asyncio
|
3
|
+
from datetime import datetime
|
4
|
+
|
5
|
+
from tbr_deal_finder.book import Book
|
6
|
+
|
7
|
+
|
8
|
+
class Retailer(abc.ABC):
|
9
|
+
"""Abstract base class for retailers."""
|
10
|
+
|
11
|
+
@property
|
12
|
+
def name(self) -> str:
|
13
|
+
raise NotImplementedError
|
14
|
+
|
15
|
+
async def get_book(
|
16
|
+
self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
|
17
|
+
) -> Book:
|
18
|
+
"""Get book information from the retailer.
|
19
|
+
|
20
|
+
- Uses Audible's product API to fetch book details
|
21
|
+
- Respects rate limiting through the provided semaphore
|
22
|
+
- Returns a Book with exists=False if the book is not found
|
23
|
+
|
24
|
+
Args:
|
25
|
+
target: Book object containing search criteria
|
26
|
+
runtime: Timestamp for when the search was initiated
|
27
|
+
semaphore: Semaphore to control concurrent requests
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
Book: Updated book object with pricing and availability
|
31
|
+
"""
|
32
|
+
raise NotImplementedError
|
33
|
+
|
34
|
+
async def set_auth(self):
|
35
|
+
raise NotImplementedError
|
36
|
+
|
37
|
+
|