tbr-deal-finder 0.1.6__py3-none-any.whl → 0.1.8__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 CHANGED
@@ -1,10 +1,10 @@
1
- import dataclasses
2
1
  import re
3
2
  from datetime import datetime
4
3
  from enum import Enum
5
- from typing import Optional, Union
4
+ from typing import Union
6
5
 
7
6
  import click
7
+ from Levenshtein import ratio
8
8
  from unidecode import unidecode
9
9
 
10
10
  from tbr_deal_finder.config import Config
@@ -14,40 +14,45 @@ _AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
14
14
 
15
15
  class BookFormat(Enum):
16
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
-
30
- # Metadata really only used for tracked books.
31
- # See get_tbr_books for more context
32
- audiobook_isbn: str = None
33
- audiobook_list_price: float = 0
34
-
35
- deleted: bool = False
36
-
37
- deal_id: Optional[str] = None
38
- exists: bool = True
39
- normalized_authors: list[str] = None
40
-
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
-
46
- if not self.deal_id:
47
- self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
48
-
49
- if isinstance(self.format, str):
50
- self.format = BookFormat(self.format)
17
+ EBOOK = "E-Book"
18
+ NA = "N/A" # When the format doesn't matter
19
+
20
+
21
+ class Book:
22
+
23
+ def __init__(
24
+ self,
25
+ retailer: str,
26
+ title: str,
27
+ authors: str,
28
+ timepoint: datetime,
29
+ format: Union[BookFormat, str],
30
+ list_price: float = 0,
31
+ current_price: float = 0,
32
+ ebook_asin: str = None,
33
+ audiobook_isbn: str = None,
34
+ audiobook_list_price: float = 0,
35
+ deleted: bool = False,
36
+ exists: bool = True,
37
+ ):
38
+ self.retailer = retailer
39
+ self.title = title.split(":")[0].split("(")[0].strip()
40
+ self.authors = authors
41
+ self.timepoint = timepoint
42
+
43
+ self.ebook_asin = ebook_asin
44
+ self.audiobook_isbn = audiobook_isbn
45
+ self.audiobook_list_price = audiobook_list_price
46
+ self.deleted = deleted
47
+ self.exists = exists
48
+
49
+ self.list_price = list_price
50
+ self.current_price = current_price
51
+ self.normalized_authors = get_normalized_authors(authors)
52
+
53
+ if isinstance(format, str):
54
+ format = BookFormat(format)
55
+ self.format = format
51
56
 
52
57
  def discount(self) -> int:
53
58
  return int((self.list_price/self.current_price - 1) * 100)
@@ -56,6 +61,10 @@ class Book:
56
61
  def price_to_string(price: float) -> str:
57
62
  return f"{Config.currency_symbol()}{price:.2f}"
58
63
 
64
+ @property
65
+ def deal_id(self) -> str:
66
+ return f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
67
+
59
68
  @property
60
69
  def title_id(self) -> str:
61
70
  return f"{self.title}__{self.normalized_authors}__{self.format}"
@@ -64,6 +73,22 @@ class Book:
64
73
  def full_title_str(self) -> str:
65
74
  return f"{self.title}__{self.normalized_authors}"
66
75
 
76
+ @property
77
+ def current_price(self) -> float:
78
+ return self._current_price
79
+
80
+ @current_price.setter
81
+ def current_price(self, price: float):
82
+ self._current_price = round(price, 2)
83
+
84
+ @property
85
+ def list_price(self) -> float:
86
+ return self._list_price
87
+
88
+ @list_price.setter
89
+ def list_price(self, price: float):
90
+ self._list_price = round(price, 2)
91
+
67
92
  def list_price_string(self):
68
93
  return self.price_to_string(self.list_price)
69
94
 
@@ -76,17 +101,31 @@ class Book:
76
101
  title = self.title
77
102
  if len(self.title) > 75:
78
103
  title = f"{title[:75]}..."
79
- return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer} - {book_format}"
104
+ return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer}"
80
105
 
81
106
  def dict(self):
82
- response = dataclasses.asdict(self)
83
- response["format"] = self.format.value
84
- del response["audiobook_isbn"]
85
- del response["audiobook_list_price"]
86
- del response["exists"]
87
- del response["normalized_authors"]
88
-
89
- return response
107
+ return {
108
+ "retailer": self.retailer,
109
+ "title": self.title,
110
+ "authors": self.authors,
111
+ "list_price": self.list_price,
112
+ "current_price": self.current_price,
113
+ "timepoint": self.timepoint,
114
+ "format": self.format.value,
115
+ "deleted": self.deleted,
116
+ "deal_id": self.deal_id,
117
+ }
118
+
119
+ def tbr_dict(self):
120
+ return {
121
+ "title": self.title,
122
+ "authors": self.authors,
123
+ "format": self.format.value,
124
+ "ebook_asin": self.ebook_asin,
125
+ "audiobook_isbn": self.audiobook_isbn,
126
+ "audiobook_list_price": self.audiobook_list_price,
127
+ "book_id": self.title_id,
128
+ }
90
129
 
91
130
 
92
131
  def get_deals_found_at(timepoint: datetime) -> list[Book]:
@@ -118,6 +157,14 @@ def print_books(books: list[Book]):
118
157
  click.echo(str(book))
119
158
 
120
159
 
160
+ def get_full_title_str(title: str, authors: Union[list, str]) -> str:
161
+ return f"{title}__{get_normalized_authors(authors)}"
162
+
163
+
164
+ def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
165
+ return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
166
+
167
+
121
168
  def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
122
169
  if isinstance(authors, str):
123
170
  authors = [i for i in authors.split(",")]
@@ -125,5 +172,18 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
125
172
  return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
126
173
 
127
174
 
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}"
175
+ def is_matching_authors(a1: list[str], a2: list[str]) -> bool:
176
+ """Checks if two normalized authors are matching.
177
+ Matching here means that they are at least 80% similar using levenshtein distance.
178
+
179
+ Score is calculated as follows:
180
+ 1 - (distance / (len1 + len2))
181
+
182
+ :param a1:
183
+ :param a2:
184
+ :return:
185
+ """
186
+ return any(
187
+ any(ratio(author1, author2, score_cutoff=.8) for author2 in a2)
188
+ for author1 in a1
189
+ )
tbr_deal_finder/cli.py CHANGED
@@ -9,11 +9,11 @@ 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 maybe_enrich_library_exports
13
12
  from tbr_deal_finder.migrations import make_migrations
14
13
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
15
14
  from tbr_deal_finder.retailer import RETAILER_MAP
16
15
  from tbr_deal_finder.retailer_deal import get_latest_deals
16
+ from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books
17
17
  from tbr_deal_finder.utils import (
18
18
  echo_err,
19
19
  echo_info,
@@ -28,12 +28,6 @@ from tbr_deal_finder.utils import (
28
28
  def cli():
29
29
  make_migrations()
30
30
 
31
- # Check that the config exists for all commands ran
32
- try:
33
- Config.load()
34
- except FileNotFoundError:
35
- _set_config()
36
-
37
31
 
38
32
  def _add_path(existing_paths: list[str]) -> Union[str, None]:
39
33
  try:
@@ -68,24 +62,26 @@ def _set_library_export_paths(config: Config):
68
62
  Ensures that only valid, unique paths are added. Updates the config in-place.
69
63
  """
70
64
  while True:
71
- if config.library_export_paths:
72
- if len(config.library_export_paths) > 1:
73
- choices = ["Add new path", "Remove path", "Done"]
74
- else:
75
- choices = ["Add new path", "Done"]
76
-
77
- try:
78
- user_selection = questionary.select(
79
- "What change would you like to make to your library export paths",
80
- choices=choices,
81
- ).ask()
82
- except (KeyError, KeyboardInterrupt, TypeError):
83
- return
65
+ if len(config.library_export_paths) > 0:
66
+ choices = ["Add new path", "Remove path", "Done"]
84
67
  else:
85
- click.echo("Add your library export path.")
86
- user_selection = "Add new path"
68
+ choices = ["Add new path", "Done"]
69
+
70
+ try:
71
+ user_selection = questionary.select(
72
+ "What change would you like to make to your library export paths",
73
+ choices=choices,
74
+ ).ask()
75
+ except (KeyError, KeyboardInterrupt, TypeError):
76
+ return
87
77
 
88
78
  if user_selection == "Done":
79
+ if not config.library_export_paths:
80
+ if not click.confirm(
81
+ "Don't add a GoodReads or StoryGraph export and use wishlist entirely? "
82
+ "Note: Wishlist checks will still work even if you add your StoryGraph/GoodReads export."
83
+ ):
84
+ continue
89
85
  return
90
86
  elif user_selection == "Add new path":
91
87
  if new_path := _add_path(config.library_export_paths):
@@ -184,13 +180,18 @@ def _set_config() -> Config:
184
180
  def setup():
185
181
  _set_config()
186
182
 
183
+ # Retailers may have changed causing some books to need reprocessing
184
+ config = Config.load()
185
+ reprocess_incomplete_tbr_books(config)
186
+
187
187
 
188
188
  @cli.command()
189
189
  def latest_deals():
190
190
  """Find book deals from your Library export."""
191
- config = Config.load()
192
-
193
- asyncio.run(maybe_enrich_library_exports(config))
191
+ try:
192
+ config = Config.load()
193
+ except FileNotFoundError:
194
+ config = _set_config()
194
195
 
195
196
  db_conn = get_duckdb_conn()
196
197
  results = execute_query(
tbr_deal_finder/config.py CHANGED
@@ -62,10 +62,16 @@ class Config:
62
62
  tracked_retailers_str = parser.get('DEFAULT', 'tracked_retailers')
63
63
  locale = parser.get('DEFAULT', 'locale', fallback="us")
64
64
  cls.set_locale(locale)
65
+
66
+ if export_paths_str:
67
+ library_export_paths = [i.strip() for i in export_paths_str.split(",")]
68
+ else:
69
+ library_export_paths = []
70
+
65
71
  return cls(
66
72
  max_price=parser.getfloat('DEFAULT', 'max_price', fallback=8.0),
67
73
  min_discount=parser.getint('DEFAULT', 'min_discount', fallback=35),
68
- library_export_paths=[i.strip() for i in export_paths_str.split(",")],
74
+ library_export_paths=library_export_paths,
69
75
  tracked_retailers=[i.strip() for i in tracked_retailers_str.split(",")]
70
76
  )
71
77
 
@@ -77,8 +83,20 @@ class Config:
77
83
  def tracked_retailers_str(self) -> str:
78
84
  return ", ".join(self.tracked_retailers)
79
85
 
86
+ def is_tracking_format(self, book_format) -> bool:
87
+ from tbr_deal_finder.retailer import RETAILER_MAP
88
+
89
+ for retailer_str in self.tracked_retailers:
90
+ retailer = RETAILER_MAP[retailer_str]()
91
+ if retailer.format == book_format:
92
+ return True
93
+
94
+ return False
95
+
80
96
  def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
81
- if isinstance(library_export_paths, str):
97
+ if not library_export_paths:
98
+ self.library_export_paths = []
99
+ elif isinstance(library_export_paths, str):
82
100
  self.library_export_paths = [i.strip() for i in library_export_paths.split(",")]
83
101
  else:
84
102
  self.library_export_paths = library_export_paths
@@ -46,6 +46,22 @@ _MIGRATIONS = [
46
46
  );
47
47
  """
48
48
  ),
49
+ TableMigration(
50
+ version=1,
51
+ table_name="tbr_book",
52
+ sql="""
53
+ CREATE TABLE tbr_book
54
+ (
55
+ title VARCHAR,
56
+ authors VARCHAR,
57
+ format VARCHAR,
58
+ ebook_asin VARCHAR,
59
+ audiobook_isbn VARCHAR,
60
+ audiobook_list_price FLOAT,
61
+ book_id VARCHAR
62
+ );
63
+ """
64
+ ),
49
65
  ]
50
66
 
51
67
 
@@ -0,0 +1,18 @@
1
+ from tbr_deal_finder.book import Book
2
+ from tbr_deal_finder.config import Config
3
+ from tbr_deal_finder.retailer import RETAILER_MAP
4
+ from tbr_deal_finder.retailer.models import Retailer
5
+
6
+
7
+ async def get_owned_books(config: Config) -> list[Book]:
8
+ owned_books = []
9
+
10
+ for retailer_str in config.tracked_retailers:
11
+ retailer: Retailer = RETAILER_MAP[retailer_str]()
12
+ await retailer.set_auth()
13
+
14
+ owned_books.extend(
15
+ await retailer.get_library(config)
16
+ )
17
+
18
+ return owned_books
@@ -1,4 +1,4 @@
1
- SELECT *
1
+ SELECT * exclude(deal_id)
2
2
  FROM retailer_deal
3
3
  QUALIFY ROW_NUMBER() OVER (PARTITION BY title, authors, retailer, format ORDER BY timepoint DESC) = 1 AND deleted IS NOT TRUE
4
4
  ORDER BY title, authors, retailer, format
@@ -1,4 +1,4 @@
1
- SELECT *
1
+ SELECT * exclude(deal_id)
2
2
  FROM retailer_deal
3
3
  WHERE timepoint = $timepoint AND deleted IS NOT TRUE
4
4
  ORDER BY title, authors, retailer, format
@@ -0,0 +1,79 @@
1
+ import os.path
2
+
3
+ import audible
4
+ import click
5
+ from audible.login import build_init_cookies
6
+ from textwrap import dedent
7
+
8
+ from tbr_deal_finder import TBR_DEALS_PATH
9
+ from tbr_deal_finder.config import Config
10
+ from tbr_deal_finder.retailer.models import Retailer
11
+
12
+ _AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
13
+
14
+
15
+ def login_url_callback(url: str) -> str:
16
+ """Helper function for login with external browsers."""
17
+
18
+ try:
19
+ from playwright.sync_api import sync_playwright # type: ignore
20
+ except ImportError:
21
+ pass
22
+ else:
23
+ with sync_playwright() as p:
24
+ iphone = p.devices["iPhone 12 Pro"]
25
+ browser = p.webkit.launch(headless=False)
26
+ context = browser.new_context(
27
+ **iphone
28
+ )
29
+ cookies = []
30
+ for name, value in build_init_cookies().items():
31
+ cookies.append(
32
+ {
33
+ "name": name,
34
+ "value": value,
35
+ "url": url
36
+ }
37
+ )
38
+ context.add_cookies(cookies)
39
+ page = browser.new_page()
40
+ page.goto(url)
41
+
42
+ while True:
43
+ page.wait_for_timeout(600)
44
+ if "/ap/maplanding" in page.url:
45
+ response_url = page.url
46
+ break
47
+
48
+ browser.close()
49
+ return response_url
50
+
51
+ message = f"""\
52
+ Please copy the following url and insert it into a web browser of your choice to log into Amazon.
53
+ Note: your browser will show you an error page (Page not found). This is expected.
54
+
55
+ {url}
56
+
57
+ Once you have logged in, please insert the copied url.
58
+ """
59
+ click.echo(dedent(message))
60
+ return input()
61
+
62
+
63
+ class Amazon(Retailer):
64
+ _auth: audible.Authenticator = None
65
+ _client: audible.AsyncClient = None
66
+
67
+ async def set_auth(self):
68
+ if not os.path.exists(_AUTH_PATH):
69
+ auth = audible.Authenticator.from_login_external(
70
+ locale=Config.locale,
71
+ login_url_callback=login_url_callback
72
+ )
73
+
74
+ # Save credentials to file
75
+ auth.to_file(_AUTH_PATH)
76
+
77
+ self._auth = audible.Authenticator.from_file(_AUTH_PATH)
78
+ self._client = audible.AsyncClient(auth=self._auth)
79
+
@@ -1,74 +1,13 @@
1
1
  import asyncio
2
2
  import math
3
- import os.path
4
- from datetime import datetime
5
- from textwrap import dedent
6
3
  import readline # type: ignore
7
4
 
8
-
9
- import audible
10
- import click
11
- from audible.login import build_init_cookies
12
-
13
- from tbr_deal_finder import TBR_DEALS_PATH
14
5
  from tbr_deal_finder.config import Config
15
- from tbr_deal_finder.retailer.models import Retailer
6
+ from tbr_deal_finder.retailer.amazon import Amazon
16
7
  from tbr_deal_finder.book import Book, BookFormat
17
8
 
18
- _AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
19
-
20
-
21
- def login_url_callback(url: str) -> str:
22
- """Helper function for login with external browsers."""
23
-
24
- try:
25
- from playwright.sync_api import sync_playwright # type: ignore
26
- except ImportError:
27
- pass
28
- else:
29
- with sync_playwright() as p:
30
- iphone = p.devices["iPhone 12 Pro"]
31
- browser = p.webkit.launch(headless=False)
32
- context = browser.new_context(
33
- **iphone
34
- )
35
- cookies = []
36
- for name, value in build_init_cookies().items():
37
- cookies.append(
38
- {
39
- "name": name,
40
- "value": value,
41
- "url": url
42
- }
43
- )
44
- context.add_cookies(cookies)
45
- page = browser.new_page()
46
- page.goto(url)
47
-
48
- while True:
49
- page.wait_for_timeout(600)
50
- if "/ap/maplanding" in page.url:
51
- response_url = page.url
52
- break
53
-
54
- browser.close()
55
- return response_url
56
-
57
- message = f"""\
58
- Please copy the following url and insert it into a web browser of your choice to log into Amazon.
59
- Note: your browser will show you an error page (Page not found). This is expected.
60
-
61
- {url}
62
-
63
- Once you have logged in, please insert the copied url.
64
- """
65
- click.echo(dedent(message))
66
- return input()
67
-
68
9
 
69
- class Audible(Retailer):
70
- _auth: audible.Authenticator = None
71
- _client: audible.AsyncClient = None
10
+ class Audible(Amazon):
72
11
 
73
12
  @property
74
13
  def name(self) -> str:
@@ -78,23 +17,9 @@ class Audible(Retailer):
78
17
  def format(self) -> BookFormat:
79
18
  return BookFormat.AUDIOBOOK
80
19
 
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
-
94
20
  async def get_book(
95
21
  self,
96
22
  target: Book,
97
- runtime: datetime,
98
23
  semaphore: asyncio.Semaphore
99
24
  ) -> Book:
100
25
  title = target.title
@@ -114,27 +39,16 @@ class Audible(Retailer):
114
39
  for product in match.get("products", []):
115
40
  if product["title"] != title:
116
41
  continue
42
+ try:
43
+ target.list_price = product["price"]["list_price"]["base"]
44
+ target.current_price = product["price"]["lowest_price"]["base"]
45
+ target.exists = True
46
+ return target
47
+ except KeyError:
48
+ continue
117
49
 
118
- return Book(
119
- retailer=self.name,
120
- title=title,
121
- authors=authors,
122
- list_price=product["price"]["list_price"]["base"],
123
- current_price=product["price"]["lowest_price"]["base"],
124
- timepoint=runtime,
125
- format=BookFormat.AUDIOBOOK
126
- )
127
-
128
- return Book(
129
- retailer=self.name,
130
- title=title,
131
- authors=authors,
132
- list_price=0,
133
- current_price=0,
134
- timepoint=runtime,
135
- format=BookFormat.AUDIOBOOK,
136
- exists=False,
137
- )
50
+ target.exists = False
51
+ return target
138
52
 
139
53
  async def get_wishlist(self, config: Config) -> list[Book]:
140
54
  wishlist_books = []
@@ -171,3 +85,39 @@ class Audible(Retailer):
171
85
  total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
172
86
 
173
87
  return wishlist_books
88
+
89
+ async def get_library(self, config: Config) -> list[Book]:
90
+ library_books = []
91
+
92
+ page = 1
93
+ total_pages = 1
94
+ page_size = 1000
95
+ while page <= total_pages:
96
+ response = await self._client.get(
97
+ "1.0/library",
98
+ num_results=page_size,
99
+ page=page,
100
+ response_groups=[
101
+ "contributors, product_attrs, product_desc, product_extended_attrs"
102
+ ]
103
+ )
104
+
105
+ for audiobook in response.get("items", []):
106
+ authors = [author["name"] for author in audiobook["authors"]]
107
+ library_books.append(
108
+ Book(
109
+ retailer=self.name,
110
+ title=audiobook["title"],
111
+ authors=", ".join(authors),
112
+ list_price=1,
113
+ current_price=1,
114
+ timepoint=config.run_time,
115
+ format=self.format,
116
+ audiobook_isbn=audiobook["isbn"],
117
+ )
118
+ )
119
+
120
+ page += 1
121
+ total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
122
+
123
+ return library_books