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
tbr_deal_finder/book.py
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
import dataclasses
|
2
|
+
import re
|
3
|
+
from datetime import datetime
|
4
|
+
from enum import Enum
|
5
|
+
from typing import Optional, Union
|
6
|
+
|
7
|
+
import click
|
8
|
+
from unidecode import unidecode
|
9
|
+
|
10
|
+
from tbr_deal_finder.config import Config
|
11
|
+
from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name
|
12
|
+
|
13
|
+
_AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
|
14
|
+
|
15
|
+
class BookFormat(Enum):
|
16
|
+
AUDIOBOOK = "Audiobook"
|
17
|
+
NA = "N/A" # When format does not matter
|
18
|
+
|
19
|
+
|
20
|
+
@dataclasses.dataclass
|
21
|
+
class Book:
|
22
|
+
retailer: str
|
23
|
+
title: str
|
24
|
+
authors: str
|
25
|
+
list_price: float
|
26
|
+
current_price: float
|
27
|
+
timepoint: datetime
|
28
|
+
format: Union[BookFormat, str]
|
29
|
+
audiobook_isbn: str = None
|
30
|
+
|
31
|
+
deleted: bool = False
|
32
|
+
|
33
|
+
deal_id: Optional[str] = None
|
34
|
+
exists: bool = True
|
35
|
+
normalized_authors: list[str] = None
|
36
|
+
|
37
|
+
def __post_init__(self):
|
38
|
+
if not self.deal_id:
|
39
|
+
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.retailer}__{self.format}"
|
40
|
+
|
41
|
+
if isinstance(self.format, str):
|
42
|
+
self.format = BookFormat(self.format)
|
43
|
+
|
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
|
+
def discount(self) -> int:
|
49
|
+
return int((self.list_price/self.current_price - 1) * 100)
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def price_to_string(price: float) -> str:
|
53
|
+
return f"{Config.currency_symbol()}{price:.2f}"
|
54
|
+
|
55
|
+
@property
|
56
|
+
def title_id(self) -> str:
|
57
|
+
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
58
|
+
|
59
|
+
def list_price_string(self):
|
60
|
+
return self.price_to_string(self.list_price)
|
61
|
+
|
62
|
+
def current_price_string(self):
|
63
|
+
return self.price_to_string(self.current_price)
|
64
|
+
|
65
|
+
def __str__(self) -> str:
|
66
|
+
price = self.current_price_string()
|
67
|
+
book_format = self.format.value
|
68
|
+
title = self.title
|
69
|
+
if len(self.title) > 75:
|
70
|
+
title = f"{title[:75]}..."
|
71
|
+
return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer} - {book_format}"
|
72
|
+
|
73
|
+
def dict(self):
|
74
|
+
response = dataclasses.asdict(self)
|
75
|
+
response["format"] = self.format.value
|
76
|
+
del response["audiobook_isbn"]
|
77
|
+
del response["exists"]
|
78
|
+
del response["normalized_authors"]
|
79
|
+
|
80
|
+
return response
|
81
|
+
|
82
|
+
|
83
|
+
def get_deals_found_at(timepoint: datetime) -> list[Book]:
|
84
|
+
db_conn = get_duckdb_conn()
|
85
|
+
query_response = execute_query(
|
86
|
+
db_conn,
|
87
|
+
get_query_by_name("get_deals_found_at.sql"),
|
88
|
+
{"timepoint": timepoint}
|
89
|
+
)
|
90
|
+
return [Book(**book) for book in query_response]
|
91
|
+
|
92
|
+
|
93
|
+
def get_active_deals() -> list[Book]:
|
94
|
+
db_conn = get_duckdb_conn()
|
95
|
+
query_response = execute_query(
|
96
|
+
db_conn,
|
97
|
+
get_query_by_name("get_active_deals.sql")
|
98
|
+
)
|
99
|
+
return [Book(**book) for book in query_response]
|
100
|
+
|
101
|
+
|
102
|
+
def print_books(books: list[Book]):
|
103
|
+
prior_title_id = books[0].title_id
|
104
|
+
for book in books:
|
105
|
+
if prior_title_id != book.title_id:
|
106
|
+
prior_title_id = book.title_id
|
107
|
+
click.echo()
|
108
|
+
|
109
|
+
click.echo(str(book))
|
110
|
+
|
111
|
+
|
112
|
+
def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
113
|
+
if isinstance(authors, str):
|
114
|
+
authors = [i for i in authors.split(",")]
|
115
|
+
|
116
|
+
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
@@ -0,0 +1,104 @@
|
|
1
|
+
import configparser
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from datetime import datetime
|
4
|
+
from typing import Union
|
5
|
+
|
6
|
+
from tbr_deal_finder import TBR_DEALS_PATH
|
7
|
+
|
8
|
+
_CONFIG_PATH = TBR_DEALS_PATH.joinpath("config.ini")
|
9
|
+
|
10
|
+
_LOCALE_CURRENCY_MAP = {
|
11
|
+
"us": "$",
|
12
|
+
"ca": "$",
|
13
|
+
"au": "$",
|
14
|
+
"uk": "£",
|
15
|
+
"fr": "€",
|
16
|
+
"de": "€",
|
17
|
+
"es": "€",
|
18
|
+
"it": "€",
|
19
|
+
"jp": "¥",
|
20
|
+
"in": "₹",
|
21
|
+
"br": "R$",
|
22
|
+
}
|
23
|
+
|
24
|
+
@dataclass
|
25
|
+
class Config:
|
26
|
+
library_export_paths: list[str]
|
27
|
+
tracked_retailers: list[str]
|
28
|
+
max_price: float = 8.0
|
29
|
+
min_discount: int = 35
|
30
|
+
run_time: datetime = datetime.now()
|
31
|
+
|
32
|
+
locale: str = "us" # This will be set as a class attribute below
|
33
|
+
|
34
|
+
def __post_init__(self):
|
35
|
+
if isinstance(self.library_export_paths, str):
|
36
|
+
self.set_library_export_paths(
|
37
|
+
self.library_export_paths.split(",")
|
38
|
+
)
|
39
|
+
|
40
|
+
if isinstance(self.tracked_retailers, str):
|
41
|
+
self.set_tracked_retailers(
|
42
|
+
self.tracked_retailers.split(",")
|
43
|
+
)
|
44
|
+
|
45
|
+
@classmethod
|
46
|
+
def currency_symbol(cls) -> str:
|
47
|
+
return _LOCALE_CURRENCY_MAP.get(cls.locale, "$")
|
48
|
+
|
49
|
+
@classmethod
|
50
|
+
def set_locale(cls, code: str):
|
51
|
+
cls.locale = code
|
52
|
+
|
53
|
+
@classmethod
|
54
|
+
def load(cls) -> "Config":
|
55
|
+
"""Load configuration from file or return defaults."""
|
56
|
+
if not _CONFIG_PATH.exists():
|
57
|
+
raise FileNotFoundError(f"Config file not found at {_CONFIG_PATH}")
|
58
|
+
|
59
|
+
parser = configparser.ConfigParser()
|
60
|
+
parser.read(_CONFIG_PATH)
|
61
|
+
export_paths_str = parser.get('DEFAULT', 'library_export_paths')
|
62
|
+
tracked_retailers_str = parser.get('DEFAULT', 'tracked_retailers')
|
63
|
+
locale = parser.get('DEFAULT', 'locale', fallback="us")
|
64
|
+
cls.set_locale(locale)
|
65
|
+
return cls(
|
66
|
+
max_price=parser.getfloat('DEFAULT', 'max_price', fallback=8.0),
|
67
|
+
min_discount=parser.getint('DEFAULT', 'min_discount', fallback=35),
|
68
|
+
library_export_paths=[i.strip() for i in export_paths_str.split(",")],
|
69
|
+
tracked_retailers=[i.strip() for i in tracked_retailers_str.split(",")]
|
70
|
+
)
|
71
|
+
|
72
|
+
@property
|
73
|
+
def library_export_paths_str(self) -> str:
|
74
|
+
return ", ".join(self.library_export_paths)
|
75
|
+
|
76
|
+
@property
|
77
|
+
def tracked_retailers_str(self) -> str:
|
78
|
+
return ", ".join(self.tracked_retailers)
|
79
|
+
|
80
|
+
def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
|
81
|
+
if isinstance(library_export_paths, str):
|
82
|
+
self.library_export_paths = [i.strip() for i in library_export_paths.split(",")]
|
83
|
+
else:
|
84
|
+
self.library_export_paths = library_export_paths
|
85
|
+
|
86
|
+
def set_tracked_retailers(self, tracked_retailers: Union[str, list[str]]):
|
87
|
+
if isinstance(tracked_retailers, str):
|
88
|
+
self.tracked_retailers = [i.strip() for i in tracked_retailers.split(",")]
|
89
|
+
else:
|
90
|
+
self.tracked_retailers = tracked_retailers
|
91
|
+
|
92
|
+
def save(self):
|
93
|
+
"""Save configuration to file."""
|
94
|
+
parser = configparser.ConfigParser()
|
95
|
+
parser['DEFAULT'] = {
|
96
|
+
'max_price': str(self.max_price),
|
97
|
+
'min_discount': str(self.min_discount),
|
98
|
+
'locale': type(self).locale,
|
99
|
+
'library_export_paths': self.library_export_paths_str,
|
100
|
+
'tracked_retailers': self.tracked_retailers_str
|
101
|
+
}
|
102
|
+
|
103
|
+
with open(_CONFIG_PATH, 'w') as f:
|
104
|
+
parser.write(f)
|
@@ -0,0 +1,155 @@
|
|
1
|
+
import asyncio
|
2
|
+
import csv
|
3
|
+
import shutil
|
4
|
+
import tempfile
|
5
|
+
|
6
|
+
from tqdm.asyncio import tqdm_asyncio
|
7
|
+
|
8
|
+
from tbr_deal_finder.book import Book, BookFormat
|
9
|
+
from tbr_deal_finder.config import Config
|
10
|
+
from tbr_deal_finder.retailer.librofm import LibroFM
|
11
|
+
|
12
|
+
|
13
|
+
def _get_book_authors(book: dict) -> str:
|
14
|
+
if authors := book.get('Authors'):
|
15
|
+
return authors
|
16
|
+
|
17
|
+
authors = book['Author']
|
18
|
+
if additional_authors := book.get("Additional Authors"):
|
19
|
+
authors = f"{authors}, {additional_authors}"
|
20
|
+
|
21
|
+
return authors
|
22
|
+
|
23
|
+
|
24
|
+
def _get_book_title(book: dict) -> str:
|
25
|
+
title = book['Title']
|
26
|
+
return title.split("(")[0].strip()
|
27
|
+
|
28
|
+
|
29
|
+
def _is_tbr_book(book: dict) -> bool:
|
30
|
+
if "Read Status" in book:
|
31
|
+
return book["Read Status"] == "to-read"
|
32
|
+
elif "Bookshelves" in book:
|
33
|
+
return "to-read" in book["Bookshelves"]
|
34
|
+
else:
|
35
|
+
return True
|
36
|
+
|
37
|
+
|
38
|
+
def get_tbr_books(config: Config) -> list[Book]:
|
39
|
+
tbr_book_map: dict[str: Book] = {}
|
40
|
+
for library_export_path in config.library_export_paths:
|
41
|
+
|
42
|
+
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
43
|
+
# Use csv.DictReader to get dictionaries with column headers
|
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
|
+
|
67
|
+
|
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
|
+
|
71
|
+
As opposed to trying to get that every time latest-deals is run
|
72
|
+
we're just updating the export csv once to include the ISBN.
|
73
|
+
"""
|
74
|
+
|
75
|
+
if "Libro.FM" not in config.tracked_retailers:
|
76
|
+
return
|
77
|
+
|
78
|
+
books_requiring_check_map = dict()
|
79
|
+
book_to_isbn_map = dict()
|
80
|
+
|
81
|
+
|
82
|
+
for library_export_path in config.library_export_paths:
|
83
|
+
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
84
|
+
# Use csv.DictReader to get dictionaries with column headers
|
85
|
+
for book_dict in csv.DictReader(file):
|
86
|
+
if not _is_tbr_book(book_dict):
|
87
|
+
continue
|
88
|
+
|
89
|
+
title = _get_book_title(book_dict)
|
90
|
+
authors = _get_book_authors(book_dict)
|
91
|
+
key = f'{title}__{authors}'
|
92
|
+
|
93
|
+
if "audiobook_isbn" in book_dict:
|
94
|
+
book_to_isbn_map[key] = book_dict["audiobook_isbn"]
|
95
|
+
books_requiring_check_map.pop(key, None)
|
96
|
+
elif key not in book_to_isbn_map:
|
97
|
+
books_requiring_check_map[key] = Book(
|
98
|
+
retailer="N/A",
|
99
|
+
title=title,
|
100
|
+
authors=authors,
|
101
|
+
list_price=0,
|
102
|
+
current_price=0,
|
103
|
+
timepoint=config.run_time,
|
104
|
+
format=BookFormat.NA
|
105
|
+
)
|
106
|
+
|
107
|
+
if not books_requiring_check_map:
|
108
|
+
return
|
109
|
+
|
110
|
+
libro_fm = LibroFM()
|
111
|
+
# Setting it lower to be a good user of libro on their more expensive search call
|
112
|
+
semaphore = asyncio.Semaphore(3)
|
113
|
+
|
114
|
+
# Set the audiobook isbn for Book instances in books_requiring_check_map
|
115
|
+
await tqdm_asyncio.gather(
|
116
|
+
*[
|
117
|
+
libro_fm.get_book_isbn(book, semaphore) for book in books_requiring_check_map.values()
|
118
|
+
],
|
119
|
+
desc="Getting required audiobook ISBN info"
|
120
|
+
)
|
121
|
+
|
122
|
+
# Go back and now add the audiobook_isbn
|
123
|
+
for library_export_path in config.library_export_paths:
|
124
|
+
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
125
|
+
reader = csv.DictReader(file)
|
126
|
+
field_names = list(reader.fieldnames) + ["audiobook_isbn"]
|
127
|
+
file_content = [book_dict for book_dict in reader]
|
128
|
+
if not file_content or "audiobook_isbn" in file_content[0]:
|
129
|
+
continue
|
130
|
+
|
131
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
|
132
|
+
temp_filename = temp_file.name
|
133
|
+
writer = csv.DictWriter(temp_file, fieldnames=field_names)
|
134
|
+
writer.writeheader()
|
135
|
+
|
136
|
+
for book_dict in file_content:
|
137
|
+
if _is_tbr_book(book_dict):
|
138
|
+
title = _get_book_title(book_dict)
|
139
|
+
authors = _get_book_authors(book_dict)
|
140
|
+
key = f'{title}__{authors}'
|
141
|
+
|
142
|
+
if key in book_to_isbn_map:
|
143
|
+
audiobook_isbn = book_to_isbn_map[key]
|
144
|
+
else:
|
145
|
+
book = books_requiring_check_map[key]
|
146
|
+
audiobook_isbn = book.audiobook_isbn
|
147
|
+
|
148
|
+
book_dict["audiobook_isbn"] = audiobook_isbn
|
149
|
+
else:
|
150
|
+
book_dict["audiobook_isbn"] = ""
|
151
|
+
|
152
|
+
writer.writerow(book_dict)
|
153
|
+
|
154
|
+
shutil.move(temp_filename, library_export_path)
|
155
|
+
|
tbr_deal_finder/main.py
ADDED
@@ -0,0 +1,223 @@
|
|
1
|
+
import asyncio
|
2
|
+
import os
|
3
|
+
from datetime import timedelta
|
4
|
+
from textwrap import dedent
|
5
|
+
from typing import Union
|
6
|
+
|
7
|
+
import click
|
8
|
+
import questionary
|
9
|
+
|
10
|
+
from tbr_deal_finder.config import Config
|
11
|
+
from tbr_deal_finder.library_exports import maybe_set_library_export_audiobook_isbn
|
12
|
+
from tbr_deal_finder.migrations import make_migrations
|
13
|
+
from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
|
14
|
+
from tbr_deal_finder.retailer import RETAILER_MAP
|
15
|
+
from tbr_deal_finder.retailer_deal import get_latest_deals
|
16
|
+
from tbr_deal_finder.utils import get_duckdb_conn, get_query_by_name, execute_query
|
17
|
+
|
18
|
+
|
19
|
+
@click.group()
|
20
|
+
def cli():
|
21
|
+
make_migrations()
|
22
|
+
|
23
|
+
# Check that the config exists for all commands ran
|
24
|
+
try:
|
25
|
+
Config.load()
|
26
|
+
except FileNotFoundError:
|
27
|
+
_set_config()
|
28
|
+
|
29
|
+
|
30
|
+
def _add_path(existing_paths: list[str]) -> Union[str, None]:
|
31
|
+
try:
|
32
|
+
new_path = os.path.expanduser(click.prompt("What is the new path"))
|
33
|
+
if new_path in existing_paths:
|
34
|
+
click.echo(f"{new_path} is already being tracked.\n")
|
35
|
+
return None
|
36
|
+
elif os.path.exists(new_path):
|
37
|
+
return new_path
|
38
|
+
else:
|
39
|
+
click.echo(f"Could not find {new_path}. Please try again.\n")
|
40
|
+
return _add_path(existing_paths)
|
41
|
+
except (KeyError, KeyboardInterrupt, TypeError):
|
42
|
+
return None
|
43
|
+
|
44
|
+
|
45
|
+
def _remove_path(existing_paths: list[str]) -> Union[str, None]:
|
46
|
+
try:
|
47
|
+
return questionary.select(
|
48
|
+
"Which path would you like to remove?",
|
49
|
+
choices=existing_paths,
|
50
|
+
).ask()
|
51
|
+
except (KeyError, KeyboardInterrupt, TypeError):
|
52
|
+
return None
|
53
|
+
|
54
|
+
|
55
|
+
def _set_library_export_paths(config: Config):
|
56
|
+
"""
|
57
|
+
Interactively set the paths to the user's library export files.
|
58
|
+
|
59
|
+
Allows the user to add or remove paths to their StoryGraph, Goodreads, or custom CSV export files.
|
60
|
+
Ensures that only valid, unique paths are added. Updates the config in-place.
|
61
|
+
"""
|
62
|
+
while True:
|
63
|
+
if config.library_export_paths:
|
64
|
+
if len(config.library_export_paths) > 1:
|
65
|
+
choices = ["Add new path", "Remove path", "Done"]
|
66
|
+
else:
|
67
|
+
choices = ["Add new path", "Done"]
|
68
|
+
|
69
|
+
try:
|
70
|
+
user_selection = questionary.select(
|
71
|
+
"What change would you like to make to your library export paths",
|
72
|
+
choices=choices,
|
73
|
+
).ask()
|
74
|
+
except (KeyError, KeyboardInterrupt, TypeError):
|
75
|
+
return
|
76
|
+
else:
|
77
|
+
click.echo("Add your library export path.")
|
78
|
+
user_selection = "Add new path"
|
79
|
+
|
80
|
+
if user_selection == "Done":
|
81
|
+
return
|
82
|
+
elif user_selection == "Add new path":
|
83
|
+
if new_path := _add_path(config.library_export_paths):
|
84
|
+
config.library_export_paths.append(new_path)
|
85
|
+
else:
|
86
|
+
if remove_path := _remove_path(config.library_export_paths):
|
87
|
+
config.library_export_paths.remove(remove_path)
|
88
|
+
|
89
|
+
|
90
|
+
def _set_locale(config: Config):
|
91
|
+
locale_options = {
|
92
|
+
"US and all other countries not listed": "us",
|
93
|
+
"Canada": "ca",
|
94
|
+
"UK and Ireland": "uk",
|
95
|
+
"Australia and New Zealand": "au",
|
96
|
+
"France, Belgium, Switzerland": "fr",
|
97
|
+
"Germany, Austria, Switzerland": "de",
|
98
|
+
"Japan": "jp",
|
99
|
+
"Italy": "it",
|
100
|
+
"India": "in",
|
101
|
+
"Spain": "es",
|
102
|
+
"Brazil": "br"
|
103
|
+
}
|
104
|
+
default_locale = [k for k,v in locale_options.items() if v == config.locale][0]
|
105
|
+
|
106
|
+
try:
|
107
|
+
user_selection = questionary.select(
|
108
|
+
"What change would you like to make to your library export paths",
|
109
|
+
choices=list(locale_options.keys()),
|
110
|
+
default=default_locale
|
111
|
+
).ask()
|
112
|
+
except (KeyError, KeyboardInterrupt, TypeError):
|
113
|
+
return
|
114
|
+
|
115
|
+
config.set_locale(locale_options[user_selection])
|
116
|
+
|
117
|
+
|
118
|
+
def _set_tracked_retailers(config: Config):
|
119
|
+
config.set_tracked_retailers(
|
120
|
+
questionary.checkbox(
|
121
|
+
"Select the retailers you want to check deals for. "
|
122
|
+
"Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.",
|
123
|
+
choices=[
|
124
|
+
questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
|
125
|
+
for retailer in RETAILER_MAP.keys()
|
126
|
+
]).ask()
|
127
|
+
)
|
128
|
+
|
129
|
+
|
130
|
+
def _set_config() -> Config:
|
131
|
+
try:
|
132
|
+
config = Config.load()
|
133
|
+
except FileNotFoundError:
|
134
|
+
config = Config(library_export_paths=[], tracked_retailers=list(RETAILER_MAP.keys()))
|
135
|
+
|
136
|
+
# Setting these config values are a little more involved,
|
137
|
+
# so they're broken out into their own functions
|
138
|
+
_set_library_export_paths(config)
|
139
|
+
_set_tracked_retailers(config)
|
140
|
+
_set_locale(config)
|
141
|
+
|
142
|
+
config.max_price = click.prompt(
|
143
|
+
"Enter maximum price for deals",
|
144
|
+
type=float,
|
145
|
+
default=config.max_price
|
146
|
+
)
|
147
|
+
config.min_discount = click.prompt(
|
148
|
+
"Enter minimum discount percentage",
|
149
|
+
type=int,
|
150
|
+
default=config.min_discount
|
151
|
+
)
|
152
|
+
|
153
|
+
config.save()
|
154
|
+
click.echo("Configuration saved!")
|
155
|
+
|
156
|
+
return config
|
157
|
+
|
158
|
+
|
159
|
+
@cli.command()
|
160
|
+
def setup():
|
161
|
+
_set_config()
|
162
|
+
|
163
|
+
|
164
|
+
@cli.command()
|
165
|
+
def latest_deals():
|
166
|
+
"""Find book deals from your Library export."""
|
167
|
+
config = Config.load()
|
168
|
+
|
169
|
+
asyncio.run(maybe_set_library_export_audiobook_isbn(config))
|
170
|
+
|
171
|
+
db_conn = get_duckdb_conn()
|
172
|
+
results = execute_query(
|
173
|
+
db_conn,
|
174
|
+
get_query_by_name("get_active_deals.sql")
|
175
|
+
)
|
176
|
+
last_ran = None if not results else results[0]["timepoint"]
|
177
|
+
min_age = config.run_time - timedelta(hours=8)
|
178
|
+
|
179
|
+
if not last_ran or last_ran < min_age:
|
180
|
+
try:
|
181
|
+
asyncio.run(get_latest_deals(config))
|
182
|
+
except Exception as e:
|
183
|
+
ran_successfully = False
|
184
|
+
details = f"Error getting deals: {e}"
|
185
|
+
click.echo(details)
|
186
|
+
else:
|
187
|
+
ran_successfully = True
|
188
|
+
details = ""
|
189
|
+
|
190
|
+
# Save execution results
|
191
|
+
db_conn.execute(
|
192
|
+
"INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
|
193
|
+
[config.run_time, ran_successfully, details]
|
194
|
+
)
|
195
|
+
|
196
|
+
if not ran_successfully:
|
197
|
+
# Gracefully exit on Exception raised by get_latest_deals
|
198
|
+
return
|
199
|
+
|
200
|
+
else:
|
201
|
+
click.echo(dedent("""
|
202
|
+
To prevent abuse lastest deals can only be pulled every 8 hours.
|
203
|
+
Fetching most recent deal results.\n
|
204
|
+
"""))
|
205
|
+
config.run_time = last_ran
|
206
|
+
|
207
|
+
if books := get_deals_found_at(config.run_time):
|
208
|
+
print_books(books)
|
209
|
+
else:
|
210
|
+
click.echo("No new deals found.")
|
211
|
+
|
212
|
+
|
213
|
+
@cli.command()
|
214
|
+
def active_deals():
|
215
|
+
"""Get all active deals."""
|
216
|
+
if books := get_active_deals():
|
217
|
+
print_books(books)
|
218
|
+
else:
|
219
|
+
click.echo("No deals found.")
|
220
|
+
|
221
|
+
|
222
|
+
if __name__ == '__main__':
|
223
|
+
cli()
|