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.
@@ -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,4 @@
1
+ SELECT *
2
+ FROM retailer_deal
3
+ QUALIFY ROW_NUMBER() OVER (PARTITION BY title, authors, retailer, format ORDER BY timepoint DESC) = 1 AND deleted IS NOT TRUE
4
+ ORDER BY title, authors, retailer, format
@@ -0,0 +1,4 @@
1
+ SELECT *
2
+ FROM retailer_deal
3
+ WHERE timepoint = $timepoint AND deleted IS NOT TRUE
4
+ ORDER BY title, authors, retailer, format
@@ -0,0 +1,5 @@
1
+ SELECT timepoint
2
+ FROM latest_deal_run_history
3
+ WHERE ran_successfully = TRUE
4
+ ORDER BY timepoint DESC
5
+ LIMIT 1
@@ -0,0 +1,9 @@
1
+ from tbr_deal_finder.retailer.audible import Audible
2
+ from tbr_deal_finder.retailer.chirp import Chirp
3
+ from tbr_deal_finder.retailer.librofm import LibroFM
4
+
5
+ RETAILER_MAP = {
6
+ "Audible": Audible,
7
+ "Chirp": Chirp,
8
+ "Libro.FM": LibroFM,
9
+ }
@@ -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
+