tbr-deal-finder 0.1.6__tar.gz → 0.1.8__tar.gz
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-0.1.8/CHANGELOG.md +52 -0
- tbr_deal_finder-0.1.8/DESIGN.md +113 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/PKG-INFO +8 -6
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/README.md +6 -5
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/pyproject.toml +2 -1
- tbr_deal_finder-0.1.8/tbr_deal_finder/book.py +189 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/cli.py +26 -25
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/config.py +20 -2
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/migrations.py +16 -0
- tbr_deal_finder-0.1.8/tbr_deal_finder/owned_books.py +18 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/queries/get_active_deals.sql +1 -1
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/queries/get_deals_found_at.sql +1 -1
- tbr_deal_finder-0.1.8/tbr_deal_finder/retailer/amazon.py +79 -0
- tbr_deal_finder-0.1.8/tbr_deal_finder/retailer/audible.py +123 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/retailer/chirp.py +69 -33
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/retailer/librofm.py +44 -40
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/retailer/models.py +3 -2
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/retailer_deal.py +37 -34
- tbr_deal_finder-0.1.8/tbr_deal_finder/tracked_books.py +320 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/uv.lock +52 -1
- tbr_deal_finder-0.1.6/CHANGELOG.md +0 -21
- tbr_deal_finder-0.1.6/tbr_deal_finder/book.py +0 -129
- tbr_deal_finder-0.1.6/tbr_deal_finder/library_exports.py +0 -205
- tbr_deal_finder-0.1.6/tbr_deal_finder/retailer/audible.py +0 -173
- tbr_deal_finder-0.1.6/tbr_deal_finder/tracked_books.py +0 -120
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/.github/workflows/publish-to-pypi.yaml +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/.gitignore +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/.python-version +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/LICENSE +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/__init__.py +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/retailer/__init__.py +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.8}/tbr_deal_finder/utils.py +0 -0
@@ -0,0 +1,52 @@
|
|
1
|
+
|
2
|
+
# Change Log
|
3
|
+
|
4
|
+
---
|
5
|
+
|
6
|
+
## 0.1.8 (August 13, 2025)
|
7
|
+
|
8
|
+
Notes:
|
9
|
+
* Improved performance for tracking on libro
|
10
|
+
* Preparing EBook support
|
11
|
+
|
12
|
+
BUG FIXES:
|
13
|
+
* Fixed initial login issue in libro.fm
|
14
|
+
|
15
|
+
---
|
16
|
+
|
17
|
+
## 0.1.7 (July 31, 2025)
|
18
|
+
|
19
|
+
Notes:
|
20
|
+
* tbr-deal-finder no longer shows deals on books you own in the same format.
|
21
|
+
* Example: You own Dune on Audible so it won't show on Audible, Libro, or Chirp. It will show on Kindle (you don't own the ebook)
|
22
|
+
* Improvements when attempting to match authors
|
23
|
+
* Chirp
|
24
|
+
* Libro.FM
|
25
|
+
* Users no longer need to provide an export and can instead just track deals on their wishlist
|
26
|
+
|
27
|
+
BUG FIXES:
|
28
|
+
* Fixed wishlist pagination in libro.fm
|
29
|
+
* Fixed issue forcing user to go through setup twice when running the setup command
|
30
|
+
|
31
|
+
---
|
32
|
+
|
33
|
+
## 0.1.6 (July 30, 2025)
|
34
|
+
|
35
|
+
Notes:
|
36
|
+
* tbr-deal-finder now also tracks deals on the books in your wishlist. Works for all retailers.
|
37
|
+
|
38
|
+
BUG FIXES:
|
39
|
+
* Fixed issue where no deals would display if libro is the only tracked audiobook retailer.
|
40
|
+
* Fixed retailer cli setup forcing a user to select at least two audiobook retailers.
|
41
|
+
|
42
|
+
---
|
43
|
+
|
44
|
+
## 0.1.5 (July 30, 2025)
|
45
|
+
|
46
|
+
Notes:
|
47
|
+
* Added formatting to select messages to make the messages purpose clearer.
|
48
|
+
|
49
|
+
BUG FIXES:
|
50
|
+
* Fixed issue getting books from libro and chirp too aggressively
|
51
|
+
* User must now track deals for at least one retailer
|
52
|
+
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# TBR Deal Finder Design
|
2
|
+
|
3
|
+
## Overview
|
4
|
+
TBR Deal Finder is a CLI application that helps users track price drops and find deals on books in their To-Be-Read (TBR) list across various digital book retailers. The application is built with a modular architecture that separates data ingestion, processing, and output concerns.
|
5
|
+
|
6
|
+
## Terms
|
7
|
+
### Digital Book
|
8
|
+
A book in electronic format, accessible via digital devices. Includes:
|
9
|
+
- Audiobooks
|
10
|
+
- E-books
|
11
|
+
|
12
|
+
### Digital Book Retailer
|
13
|
+
Platforms that sell or distribute digital books, such as:
|
14
|
+
- Libro.fm (audiobooks)
|
15
|
+
- Audible (audiobooks)
|
16
|
+
- Kindle (e-books)
|
17
|
+
- Chirp (discounted audiobooks)
|
18
|
+
|
19
|
+
### Library Export
|
20
|
+
A data file containing a user's reading list and status. Sources include:
|
21
|
+
- **Automated Exports**:
|
22
|
+
- The StoryGraph
|
23
|
+
- Goodreads
|
24
|
+
- **Manual CSVs**: Custom spreadsheets following the required format
|
25
|
+
|
26
|
+
### TBR (To Be Read)
|
27
|
+
A user's personal reading list of books they plan to read in the future.
|
28
|
+
|
29
|
+
## Core Components
|
30
|
+
|
31
|
+
### 1. Data Ingestion Layer
|
32
|
+
- **Library Exports**: Handles importing book data from multiple sources:
|
33
|
+
- The StoryGraph exports
|
34
|
+
- Goodreads exports
|
35
|
+
- Custom CSV files
|
36
|
+
- **Retailer APIs**: Interfaces with various digital book retailers to fetch current pricing and availability
|
37
|
+
|
38
|
+
### 2. Core Data Model
|
39
|
+
|
40
|
+
#### `Book` Class
|
41
|
+
The central data structure that represents a book across the application:
|
42
|
+
- **Purpose**: Serves as a consistent contract between different components
|
43
|
+
- **Key Attributes**:
|
44
|
+
- Title and author information
|
45
|
+
- Format (Audiobook, Ebook, etc.)
|
46
|
+
- Pricing information (list price, current price)
|
47
|
+
- Retailer-specific metadata
|
48
|
+
- Timestamp for deal tracking
|
49
|
+
|
50
|
+
### 3. Retailer Interface
|
51
|
+
|
52
|
+
#### `Retailer` Base Class
|
53
|
+
Abstract base class that defines the interface for all retailer implementations:
|
54
|
+
- **Core Methods**:
|
55
|
+
- `get_book()`: Fetches book details from the retailer
|
56
|
+
- `get_wishlist()`: Retrieves the user's wishlist
|
57
|
+
- `get_library()`: Gets the user's purchased/owned books
|
58
|
+
- **Current Implementations**:
|
59
|
+
- Audible
|
60
|
+
- Libro.fm
|
61
|
+
- Chirp
|
62
|
+
- Kindle (planned)
|
63
|
+
|
64
|
+
### 4. Processing Pipeline
|
65
|
+
1. **Data Collection**:
|
66
|
+
- Load TBR lists from configured sources
|
67
|
+
- Fetch owned books from retailers
|
68
|
+
- Retrieve current deals and pricing
|
69
|
+
|
70
|
+
2. **Matching Logic**:
|
71
|
+
- Matches TBR books with retailer inventory
|
72
|
+
- Filters out owned books
|
73
|
+
- Identifies price drops and deals
|
74
|
+
|
75
|
+
3. **Output Generation**:
|
76
|
+
- Formats results for CLI display
|
77
|
+
- Highlights best deals
|
78
|
+
- Provides purchase links
|
79
|
+
|
80
|
+
## Data Flow
|
81
|
+
|
82
|
+
```mermaid
|
83
|
+
graph TD
|
84
|
+
A[Library Exports] -->|CSV/JSON| B[Book Objects]
|
85
|
+
C[Retailer APIs] -->|API Calls| B
|
86
|
+
B --> D[Deal Processing]
|
87
|
+
D --> E[CLI Output]
|
88
|
+
F[User Configuration] -->|Settings| C
|
89
|
+
F -->|Preferences| D
|
90
|
+
```
|
91
|
+
|
92
|
+
## Key Design Decisions
|
93
|
+
|
94
|
+
### 1. Extensibility
|
95
|
+
- Retailer implementations are pluggable through the `Retailer` base class
|
96
|
+
- Support for multiple export formats and retailer APIs
|
97
|
+
|
98
|
+
### 2. Performance
|
99
|
+
- Asynchronous I/O for API calls
|
100
|
+
- Caching of retailer responses
|
101
|
+
- Efficient data structures for book matching
|
102
|
+
|
103
|
+
### 3. User Experience
|
104
|
+
- Clear, concise command-line interface
|
105
|
+
- Configurable output formats
|
106
|
+
- Progress indicators for long-running operations
|
107
|
+
|
108
|
+
## Future Considerations
|
109
|
+
- Support for more retailer APIs
|
110
|
+
- Email notifications for price drops
|
111
|
+
- Web interface for easier configuration
|
112
|
+
- Integration with library lending services
|
113
|
+
- Price history tracking
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.8
|
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
|
@@ -9,6 +9,7 @@ Requires-Dist: aiohttp>=3.12.14
|
|
9
9
|
Requires-Dist: audible==0.8.2
|
10
10
|
Requires-Dist: click>=8.2.1
|
11
11
|
Requires-Dist: duckdb>=1.3.2
|
12
|
+
Requires-Dist: levenshtein>=0.27.1
|
12
13
|
Requires-Dist: pandas>=2.3.1
|
13
14
|
Requires-Dist: questionary>=2.1.0
|
14
15
|
Requires-Dist: tqdm>=4.67.1
|
@@ -17,16 +18,17 @@ Description-Content-Type: text/markdown
|
|
17
18
|
|
18
19
|
# tbr-deal-finder
|
19
20
|
|
20
|
-
Track price drops and find deals on books in your TBR (To Be Read)
|
21
|
+
Track price drops and find deals on books in your TBR (To Be Read) and wishlist across digital book retailers.
|
21
22
|
|
22
23
|
## Features
|
23
|
-
-
|
24
|
+
- Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
24
25
|
- Supports multiple of the library exports above
|
25
26
|
- Tracks deals on the wishlist of all your configured retailers like audible
|
26
27
|
- Supports multiple locales and currencies
|
27
|
-
-
|
28
|
+
- Find the latest and active deals from supported sellers
|
28
29
|
- Simple CLI interface for setup and usage
|
29
30
|
- Only get notified for new deals or view all active deals
|
31
|
+
- Filters out books you already own to prevent purchasing the same book on multiple retailers
|
30
32
|
|
31
33
|
## Support
|
32
34
|
|
@@ -54,7 +56,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
54
56
|
1. If it's not already on your computer, download Python https://www.python.org/downloads/
|
55
57
|
1. tbr-deal-finder requires Python3.13 or higher
|
56
58
|
2. Optional: Install and use virtualenv
|
57
|
-
3. Open your Terminal/
|
59
|
+
3. Open your Terminal/Command Prompt
|
58
60
|
4. Run `pip3.13 install tbr-deal-finder`
|
59
61
|
|
60
62
|
### UV
|
@@ -67,7 +69,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
67
69
|
https://docs.astral.sh/uv/getting-started/installation/
|
68
70
|
|
69
71
|
## Configuration
|
70
|
-
This tool
|
72
|
+
This tool can use the csv generated by the app you use to track your TBRs.
|
71
73
|
Here are the steps to get your export.
|
72
74
|
|
73
75
|
### StoryGraph
|
@@ -1,15 +1,16 @@
|
|
1
1
|
# tbr-deal-finder
|
2
2
|
|
3
|
-
Track price drops and find deals on books in your TBR (To Be Read)
|
3
|
+
Track price drops and find deals on books in your TBR (To Be Read) and wishlist across digital book retailers.
|
4
4
|
|
5
5
|
## Features
|
6
|
-
-
|
6
|
+
- Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
7
7
|
- Supports multiple of the library exports above
|
8
8
|
- Tracks deals on the wishlist of all your configured retailers like audible
|
9
9
|
- Supports multiple locales and currencies
|
10
|
-
-
|
10
|
+
- Find the latest and active deals from supported sellers
|
11
11
|
- Simple CLI interface for setup and usage
|
12
12
|
- Only get notified for new deals or view all active deals
|
13
|
+
- Filters out books you already own to prevent purchasing the same book on multiple retailers
|
13
14
|
|
14
15
|
## Support
|
15
16
|
|
@@ -37,7 +38,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
37
38
|
1. If it's not already on your computer, download Python https://www.python.org/downloads/
|
38
39
|
1. tbr-deal-finder requires Python3.13 or higher
|
39
40
|
2. Optional: Install and use virtualenv
|
40
|
-
3. Open your Terminal/
|
41
|
+
3. Open your Terminal/Command Prompt
|
41
42
|
4. Run `pip3.13 install tbr-deal-finder`
|
42
43
|
|
43
44
|
### UV
|
@@ -50,7 +51,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
50
51
|
https://docs.astral.sh/uv/getting-started/installation/
|
51
52
|
|
52
53
|
## Configuration
|
53
|
-
This tool
|
54
|
+
This tool can use the csv generated by the app you use to track your TBRs.
|
54
55
|
Here are the steps to get your export.
|
55
56
|
|
56
57
|
### StoryGraph
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "tbr-deal-finder"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.8"
|
4
4
|
description = "Track price drops and find deals on books in your TBR list across audiobook and ebook formats."
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.13"
|
@@ -10,6 +10,7 @@ dependencies = [
|
|
10
10
|
"audible==0.8.2",
|
11
11
|
"click>=8.2.1",
|
12
12
|
"duckdb>=1.3.2",
|
13
|
+
"levenshtein>=0.27.1",
|
13
14
|
"pandas>=2.3.1",
|
14
15
|
"questionary>=2.1.0",
|
15
16
|
"tqdm>=4.67.1",
|
@@ -0,0 +1,189 @@
|
|
1
|
+
import re
|
2
|
+
from datetime import datetime
|
3
|
+
from enum import Enum
|
4
|
+
from typing import Union
|
5
|
+
|
6
|
+
import click
|
7
|
+
from Levenshtein import ratio
|
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
|
+
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
|
56
|
+
|
57
|
+
def discount(self) -> int:
|
58
|
+
return int((self.list_price/self.current_price - 1) * 100)
|
59
|
+
|
60
|
+
@staticmethod
|
61
|
+
def price_to_string(price: float) -> str:
|
62
|
+
return f"{Config.currency_symbol()}{price:.2f}"
|
63
|
+
|
64
|
+
@property
|
65
|
+
def deal_id(self) -> str:
|
66
|
+
return f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
67
|
+
|
68
|
+
@property
|
69
|
+
def title_id(self) -> str:
|
70
|
+
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
71
|
+
|
72
|
+
@property
|
73
|
+
def full_title_str(self) -> str:
|
74
|
+
return f"{self.title}__{self.normalized_authors}"
|
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
|
+
|
92
|
+
def list_price_string(self):
|
93
|
+
return self.price_to_string(self.list_price)
|
94
|
+
|
95
|
+
def current_price_string(self):
|
96
|
+
return self.price_to_string(self.current_price)
|
97
|
+
|
98
|
+
def __str__(self) -> str:
|
99
|
+
price = self.current_price_string()
|
100
|
+
book_format = self.format.value
|
101
|
+
title = self.title
|
102
|
+
if len(self.title) > 75:
|
103
|
+
title = f"{title[:75]}..."
|
104
|
+
return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer}"
|
105
|
+
|
106
|
+
def dict(self):
|
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
|
+
}
|
129
|
+
|
130
|
+
|
131
|
+
def get_deals_found_at(timepoint: datetime) -> list[Book]:
|
132
|
+
db_conn = get_duckdb_conn()
|
133
|
+
query_response = execute_query(
|
134
|
+
db_conn,
|
135
|
+
get_query_by_name("get_deals_found_at.sql"),
|
136
|
+
{"timepoint": timepoint}
|
137
|
+
)
|
138
|
+
return [Book(**book) for book in query_response]
|
139
|
+
|
140
|
+
|
141
|
+
def get_active_deals() -> list[Book]:
|
142
|
+
db_conn = get_duckdb_conn()
|
143
|
+
query_response = execute_query(
|
144
|
+
db_conn,
|
145
|
+
get_query_by_name("get_active_deals.sql")
|
146
|
+
)
|
147
|
+
return [Book(**book) for book in query_response]
|
148
|
+
|
149
|
+
|
150
|
+
def print_books(books: list[Book]):
|
151
|
+
prior_title_id = books[0].title_id
|
152
|
+
for book in books:
|
153
|
+
if prior_title_id != book.title_id:
|
154
|
+
prior_title_id = book.title_id
|
155
|
+
click.echo()
|
156
|
+
|
157
|
+
click.echo(str(book))
|
158
|
+
|
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
|
+
|
168
|
+
def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
169
|
+
if isinstance(authors, str):
|
170
|
+
authors = [i for i in authors.split(",")]
|
171
|
+
|
172
|
+
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
173
|
+
|
174
|
+
|
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
|
+
)
|
@@ -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
|
-
|
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
|
-
|
86
|
-
|
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
|
-
|
192
|
-
|
193
|
-
|
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(
|
@@ -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=
|
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
|
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
|