tbr-deal-finder 0.1.6__tar.gz → 0.1.7__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.6 → tbr_deal_finder-0.1.7}/CHANGELOG.md +18 -0
- tbr_deal_finder-0.1.7/DESIGN.md +113 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/PKG-INFO +7 -5
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/README.md +5 -4
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/pyproject.toml +2 -1
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/book.py +28 -2
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/cli.py +21 -22
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/config.py +10 -2
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/library_exports.py +6 -3
- tbr_deal_finder-0.1.7/tbr_deal_finder/owned_books.py +18 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/audible.py +36 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/chirp.py +61 -2
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/librofm.py +34 -3
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/models.py +2 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer_deal.py +43 -30
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/uv.lock +52 -1
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/.github/workflows/publish-to-pypi.yaml +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/.gitignore +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/.python-version +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/LICENSE +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/__init__.py +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/migrations.py +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/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.7}/tbr_deal_finder/retailer/__init__.py +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/tracked_books.py +0 -0
- {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/utils.py +0 -0
@@ -1,6 +1,22 @@
|
|
1
1
|
|
2
2
|
# Change Log
|
3
3
|
|
4
|
+
## 0.1.7 (July 31, 2025)
|
5
|
+
|
6
|
+
Notes:
|
7
|
+
* tbr-deal-finder no longer shows deals on books you own in the same format.
|
8
|
+
* 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)
|
9
|
+
* Improvements when attempting to match authors
|
10
|
+
* Chirp
|
11
|
+
* Libro.FM
|
12
|
+
* Users no longer need to provide an export and can instead just track deals on their wishlist
|
13
|
+
|
14
|
+
BUG FIXES:
|
15
|
+
* Fixed wishlist pagination in libro.fm
|
16
|
+
* Fixed issue forcing user to go through setup twice when running the setup command
|
17
|
+
|
18
|
+
---
|
19
|
+
|
4
20
|
## 0.1.6 (July 30, 2025)
|
5
21
|
|
6
22
|
Notes:
|
@@ -10,6 +26,8 @@ BUG FIXES:
|
|
10
26
|
* Fixed issue where no deals would display if libro is the only tracked audiobook retailer.
|
11
27
|
* Fixed retailer cli setup forcing a user to select at least two audiobook retailers.
|
12
28
|
|
29
|
+
---
|
30
|
+
|
13
31
|
## 0.1.5 (July 30, 2025)
|
14
32
|
|
15
33
|
Notes:
|
@@ -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.7
|
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
|
|
@@ -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
|
|
@@ -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.7"
|
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",
|
@@ -5,6 +5,7 @@ from enum import Enum
|
|
5
5
|
from typing import Optional, Union
|
6
6
|
|
7
7
|
import click
|
8
|
+
from Levenshtein import ratio
|
8
9
|
from unidecode import unidecode
|
9
10
|
|
10
11
|
from tbr_deal_finder.config import Config
|
@@ -43,6 +44,10 @@ class Book:
|
|
43
44
|
self.list_price = round(self.list_price, 2)
|
44
45
|
self.normalized_authors = get_normalized_authors(self.authors)
|
45
46
|
|
47
|
+
# Strip the title down to its most basic repr
|
48
|
+
# Improves hit rate on retailers
|
49
|
+
self.title = self.title.split(":")[0].split("(")[0].strip()
|
50
|
+
|
46
51
|
if not self.deal_id:
|
47
52
|
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
48
53
|
|
@@ -118,6 +123,14 @@ def print_books(books: list[Book]):
|
|
118
123
|
click.echo(str(book))
|
119
124
|
|
120
125
|
|
126
|
+
def get_full_title_str(title: str, authors: Union[list, str]) -> str:
|
127
|
+
return f"{title}__{get_normalized_authors(authors)}"
|
128
|
+
|
129
|
+
|
130
|
+
def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
|
131
|
+
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
132
|
+
|
133
|
+
|
121
134
|
def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
122
135
|
if isinstance(authors, str):
|
123
136
|
authors = [i for i in authors.split(",")]
|
@@ -125,5 +138,18 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
|
125
138
|
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
126
139
|
|
127
140
|
|
128
|
-
def
|
129
|
-
|
141
|
+
def is_matching_authors(a1: list[str], a2: list[str]) -> bool:
|
142
|
+
"""Checks if two normalized authors are matching.
|
143
|
+
Matching here means that they are at least 80% similar using levenshtein distance.
|
144
|
+
|
145
|
+
Score is calculated as follows:
|
146
|
+
1 - (distance / (len1 + len2))
|
147
|
+
|
148
|
+
:param a1:
|
149
|
+
:param a2:
|
150
|
+
:return:
|
151
|
+
"""
|
152
|
+
return any(
|
153
|
+
any(ratio(author1, author2, score_cutoff=.8) for author2 in a2)
|
154
|
+
for author1 in a1
|
155
|
+
)
|
@@ -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):
|
@@ -188,7 +184,10 @@ def setup():
|
|
188
184
|
@cli.command()
|
189
185
|
def latest_deals():
|
190
186
|
"""Find book deals from your Library export."""
|
191
|
-
|
187
|
+
try:
|
188
|
+
config = Config.load()
|
189
|
+
except FileNotFoundError:
|
190
|
+
config = _set_config()
|
192
191
|
|
193
192
|
asyncio.run(maybe_enrich_library_exports(config))
|
194
193
|
|
@@ -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
|
|
@@ -78,7 +84,9 @@ class Config:
|
|
78
84
|
return ", ".join(self.tracked_retailers)
|
79
85
|
|
80
86
|
def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
|
81
|
-
if
|
87
|
+
if not library_export_paths:
|
88
|
+
self.library_export_paths = []
|
89
|
+
elif isinstance(library_export_paths, str):
|
82
90
|
self.library_export_paths = [i.strip() for i in library_export_paths.split(",")]
|
83
91
|
else:
|
84
92
|
self.library_export_paths = library_export_paths
|
@@ -7,7 +7,7 @@ from typing import Callable, Awaitable, Optional
|
|
7
7
|
|
8
8
|
from tqdm.asyncio import tqdm_asyncio
|
9
9
|
|
10
|
-
from tbr_deal_finder.book import Book, BookFormat,
|
10
|
+
from tbr_deal_finder.book import Book, BookFormat, get_full_title_str
|
11
11
|
from tbr_deal_finder.config import Config
|
12
12
|
from tbr_deal_finder.retailer import LibroFM, Chirp
|
13
13
|
|
@@ -61,6 +61,9 @@ async def _maybe_set_column_for_library_exports(
|
|
61
61
|
:param column_name:
|
62
62
|
:return:
|
63
63
|
"""
|
64
|
+
if not config.library_export_paths:
|
65
|
+
return
|
66
|
+
|
64
67
|
if not column_name:
|
65
68
|
column_name = attr_name
|
66
69
|
|
@@ -77,7 +80,7 @@ async def _maybe_set_column_for_library_exports(
|
|
77
80
|
|
78
81
|
title = get_book_title(book_dict)
|
79
82
|
authors = get_book_authors(book_dict)
|
80
|
-
key =
|
83
|
+
key = get_full_title_str(title, authors)
|
81
84
|
|
82
85
|
if column_name in book_dict:
|
83
86
|
# Keep state of value for this book/key
|
@@ -136,7 +139,7 @@ async def _maybe_set_column_for_library_exports(
|
|
136
139
|
if is_tbr_book(book_dict):
|
137
140
|
title = get_book_title(book_dict)
|
138
141
|
authors = get_book_authors(book_dict)
|
139
|
-
key =
|
142
|
+
key = get_full_title_str(title, authors)
|
140
143
|
|
141
144
|
if key in book_to_col_val_map:
|
142
145
|
col_val = book_to_col_val_map[key]
|
@@ -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
|
@@ -171,3 +171,39 @@ class Audible(Retailer):
|
|
171
171
|
total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
|
172
172
|
|
173
173
|
return wishlist_books
|
174
|
+
|
175
|
+
async def get_library(self, config: Config) -> list[Book]:
|
176
|
+
library_books = []
|
177
|
+
|
178
|
+
page = 1
|
179
|
+
total_pages = 1
|
180
|
+
page_size = 1000
|
181
|
+
while page <= total_pages:
|
182
|
+
response = await self._client.get(
|
183
|
+
"1.0/library",
|
184
|
+
num_results=page_size,
|
185
|
+
page=page,
|
186
|
+
response_groups=[
|
187
|
+
"contributors, product_attrs, product_desc, product_extended_attrs"
|
188
|
+
]
|
189
|
+
)
|
190
|
+
|
191
|
+
for audiobook in response.get("items", []):
|
192
|
+
authors = [author["name"] for author in audiobook["authors"]]
|
193
|
+
library_books.append(
|
194
|
+
Book(
|
195
|
+
retailer=self.name,
|
196
|
+
title=audiobook["title"],
|
197
|
+
authors=", ".join(authors),
|
198
|
+
list_price=1,
|
199
|
+
current_price=1,
|
200
|
+
timepoint=config.run_time,
|
201
|
+
format=self.format,
|
202
|
+
audiobook_isbn=audiobook["isbn"],
|
203
|
+
)
|
204
|
+
)
|
205
|
+
|
206
|
+
page += 1
|
207
|
+
total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
|
208
|
+
|
209
|
+
return library_books
|
@@ -2,6 +2,7 @@ import asyncio
|
|
2
2
|
import json
|
3
3
|
import os
|
4
4
|
from datetime import datetime, timedelta
|
5
|
+
from textwrap import dedent
|
5
6
|
|
6
7
|
import aiohttp
|
7
8
|
import click
|
@@ -9,7 +10,7 @@ import click
|
|
9
10
|
from tbr_deal_finder import TBR_DEALS_PATH
|
10
11
|
from tbr_deal_finder.config import Config
|
11
12
|
from tbr_deal_finder.retailer.models import Retailer
|
12
|
-
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
13
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
13
14
|
from tbr_deal_finder.utils import currency_to_float, echo_err
|
14
15
|
|
15
16
|
|
@@ -120,7 +121,7 @@ class Chirp(Retailer):
|
|
120
121
|
normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
|
121
122
|
if (
|
122
123
|
book["displayTitle"] == title
|
123
|
-
and
|
124
|
+
and is_matching_authors(target.normalized_authors, normalized_authors)
|
124
125
|
):
|
125
126
|
return Book(
|
126
127
|
retailer=self.name,
|
@@ -180,3 +181,61 @@ class Chirp(Retailer):
|
|
180
181
|
)
|
181
182
|
|
182
183
|
page += 1
|
184
|
+
|
185
|
+
async def get_library(self, config: Config) -> list[Book]:
|
186
|
+
library_books = []
|
187
|
+
page = 1
|
188
|
+
query = dedent("""
|
189
|
+
query AndroidCurrentUserAudiobooks($page: Int!, $pageSize: Int!) {
|
190
|
+
currentUserAudiobooks(page: $page, pageSize: $pageSize, sort: TITLE_A_Z, clientCapabilities: [CHIRP_AUDIO]) {
|
191
|
+
audiobook {
|
192
|
+
id
|
193
|
+
allAuthors{name}
|
194
|
+
displayTitle
|
195
|
+
displayAuthors
|
196
|
+
displayNarrators
|
197
|
+
durationMs
|
198
|
+
description
|
199
|
+
publisher
|
200
|
+
}
|
201
|
+
archived
|
202
|
+
playable
|
203
|
+
finishedAt
|
204
|
+
currentOverallOffsetMs
|
205
|
+
}
|
206
|
+
}
|
207
|
+
""")
|
208
|
+
|
209
|
+
while True:
|
210
|
+
response = await self.make_request(
|
211
|
+
"POST",
|
212
|
+
json={
|
213
|
+
"query": query,
|
214
|
+
"variables": {"page": page, "pageSize": 15},
|
215
|
+
"operationName": "AndroidCurrentUserAudiobooks"
|
216
|
+
}
|
217
|
+
)
|
218
|
+
|
219
|
+
audiobooks = response.get(
|
220
|
+
"data", {}
|
221
|
+
).get("currentUserAudiobooks", [])
|
222
|
+
|
223
|
+
if not audiobooks:
|
224
|
+
return library_books
|
225
|
+
|
226
|
+
for book in audiobooks:
|
227
|
+
audiobook = book["audiobook"]
|
228
|
+
authors = [author["name"] for author in audiobook["allAuthors"]]
|
229
|
+
library_books.append(
|
230
|
+
Book(
|
231
|
+
retailer=self.name,
|
232
|
+
title=audiobook["displayTitle"],
|
233
|
+
authors=", ".join(authors),
|
234
|
+
list_price=1,
|
235
|
+
current_price=1,
|
236
|
+
timepoint=config.run_time,
|
237
|
+
format=self.format,
|
238
|
+
)
|
239
|
+
)
|
240
|
+
|
241
|
+
page += 1
|
@@ -10,7 +10,7 @@ import click
|
|
10
10
|
from tbr_deal_finder import TBR_DEALS_PATH
|
11
11
|
from tbr_deal_finder.config import Config
|
12
12
|
from tbr_deal_finder.retailer.models import Retailer
|
13
|
-
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
13
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
|
14
14
|
from tbr_deal_finder.utils import currency_to_float
|
15
15
|
|
16
16
|
|
@@ -101,7 +101,7 @@ class LibroFM(Retailer):
|
|
101
101
|
|
102
102
|
if (
|
103
103
|
title == b["title"]
|
104
|
-
and
|
104
|
+
and is_matching_authors(book.normalized_authors, normalized_authors)
|
105
105
|
):
|
106
106
|
book.audiobook_isbn = b["isbn"]
|
107
107
|
break
|
@@ -165,7 +165,7 @@ class LibroFM(Retailer):
|
|
165
165
|
response = await self.make_request(
|
166
166
|
f"api/v10/explore/wishlist",
|
167
167
|
"GET",
|
168
|
-
params=dict(page=
|
168
|
+
params=dict(page=page)
|
169
169
|
)
|
170
170
|
wishlist = response.get("data", {}).get("wishlist", {})
|
171
171
|
if not wishlist:
|
@@ -189,3 +189,34 @@ class LibroFM(Retailer):
|
|
189
189
|
total_pages = wishlist["total_pages"]
|
190
190
|
|
191
191
|
return wishlist_books
|
192
|
+
|
193
|
+
async def get_library(self, config: Config) -> list[Book]:
|
194
|
+
library_books = []
|
195
|
+
|
196
|
+
page = 1
|
197
|
+
total_pages = 1
|
198
|
+
while page <= total_pages:
|
199
|
+
response = await self.make_request(
|
200
|
+
f"api/v10/library",
|
201
|
+
"GET",
|
202
|
+
params=dict(page=page)
|
203
|
+
)
|
204
|
+
|
205
|
+
for book in response.get("audiobooks", []):
|
206
|
+
library_books.append(
|
207
|
+
Book(
|
208
|
+
retailer=self.name,
|
209
|
+
title=book["title"],
|
210
|
+
authors=", ".join(book["authors"]),
|
211
|
+
list_price=1,
|
212
|
+
current_price=1,
|
213
|
+
timepoint=config.run_time,
|
214
|
+
format=self.format,
|
215
|
+
audiobook_isbn=book["isbn"],
|
216
|
+
)
|
217
|
+
)
|
218
|
+
|
219
|
+
page += 1
|
220
|
+
total_pages = response["total_pages"]
|
221
|
+
|
222
|
+
return library_books
|
@@ -8,6 +8,7 @@ from tqdm.asyncio import tqdm_asyncio
|
|
8
8
|
|
9
9
|
from tbr_deal_finder.book import Book, get_active_deals, BookFormat
|
10
10
|
from tbr_deal_finder.config import Config
|
11
|
+
from tbr_deal_finder.owned_books import get_owned_books
|
11
12
|
from tbr_deal_finder.tracked_books import get_tbr_books
|
12
13
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
13
14
|
from tbr_deal_finder.retailer.models import Retailer
|
@@ -41,7 +42,7 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
41
42
|
# Any remaining values in active_deal_map mean that
|
42
43
|
# it wasn't found and should be marked for deletion
|
43
44
|
for deal in active_deal_map.values():
|
44
|
-
echo_warning(f"{str(deal)} is no longer active")
|
45
|
+
echo_warning(f"{str(deal)} is no longer active\n")
|
45
46
|
deal.timepoint = config.run_time
|
46
47
|
deal.deleted = True
|
47
48
|
df_data.append(deal.dict())
|
@@ -55,21 +56,6 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
|
|
55
56
|
db_conn.unregister("_df")
|
56
57
|
|
57
58
|
|
58
|
-
def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
|
59
|
-
response = []
|
60
|
-
found_book_set = {f'{b.title} - {b.authors}' for b in found_books}
|
61
|
-
for book in all_books:
|
62
|
-
if ":" not in book.title:
|
63
|
-
continue
|
64
|
-
|
65
|
-
if f'{book.title} - {book.authors}' not in found_book_set:
|
66
|
-
alt_book = copy.deepcopy(book)
|
67
|
-
alt_book.title = alt_book.title.split(":")[0]
|
68
|
-
response.append(alt_book)
|
69
|
-
|
70
|
-
return response
|
71
|
-
|
72
|
-
|
73
59
|
async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
|
74
60
|
"""Get Books with limited concurrency.
|
75
61
|
|
@@ -100,13 +86,9 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
|
|
100
86
|
elif not book.exists:
|
101
87
|
unresolved_books.append(book)
|
102
88
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
elif unresolved_books:
|
107
|
-
click.echo()
|
108
|
-
for book in unresolved_books:
|
109
|
-
echo_info(f"{book.title} by {book.authors} not found")
|
89
|
+
click.echo()
|
90
|
+
for book in unresolved_books:
|
91
|
+
echo_info(f"{book.title} by {book.authors} not found")
|
110
92
|
|
111
93
|
return response
|
112
94
|
|
@@ -145,6 +127,34 @@ def _apply_proper_list_prices(books: list[Book]):
|
|
145
127
|
book.list_price = max(book.current_price, list_price)
|
146
128
|
|
147
129
|
|
130
|
+
def _get_retailer_relevant_tbr_books(
|
131
|
+
retailer: Retailer,
|
132
|
+
books: list[Book],
|
133
|
+
owned_book_title_map: dict[str, dict[BookFormat, Book]],
|
134
|
+
) -> list[Book]:
|
135
|
+
"""
|
136
|
+
Don't check on deals in a specified format that does not match the format the retailer sells.
|
137
|
+
Also, don't check on deals for a book if a copy is already owned in that same format.
|
138
|
+
|
139
|
+
:param retailer:
|
140
|
+
:param books:
|
141
|
+
:param owned_book_title_map:
|
142
|
+
:return:
|
143
|
+
"""
|
144
|
+
|
145
|
+
response = []
|
146
|
+
|
147
|
+
for book in books:
|
148
|
+
owned_versions = owned_book_title_map[book.full_title_str]
|
149
|
+
if (
|
150
|
+
(book.format == BookFormat.NA or book.format == retailer.format)
|
151
|
+
and retailer.format not in owned_versions
|
152
|
+
):
|
153
|
+
response.append(book)
|
154
|
+
|
155
|
+
return response
|
156
|
+
|
157
|
+
|
148
158
|
async def get_latest_deals(config: Config):
|
149
159
|
"""
|
150
160
|
Fetches the latest book deals from all tracked retailers for the user's TBR list.
|
@@ -164,18 +174,21 @@ async def get_latest_deals(config: Config):
|
|
164
174
|
|
165
175
|
books: list[Book] = []
|
166
176
|
tbr_books = await get_tbr_books(config)
|
177
|
+
owned_books = await get_owned_books(config)
|
178
|
+
|
179
|
+
owned_book_title_map: dict[str, dict[BookFormat, Book]] = defaultdict(dict)
|
180
|
+
for book in owned_books:
|
181
|
+
owned_book_title_map[book.full_title_str][book.format] = book
|
167
182
|
|
168
183
|
for retailer_str in config.tracked_retailers:
|
169
184
|
retailer = RETAILER_MAP[retailer_str]()
|
170
185
|
await retailer.set_auth()
|
171
186
|
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
if book.format == BookFormat.NA or book.format == retailer.format
|
178
|
-
]
|
187
|
+
relevant_tbr_books = _get_retailer_relevant_tbr_books(
|
188
|
+
retailer,
|
189
|
+
tbr_books,
|
190
|
+
owned_book_title_map,
|
191
|
+
)
|
179
192
|
|
180
193
|
echo_info(f"Getting deals from {retailer.name}")
|
181
194
|
click.echo("\n---------------")
|
@@ -244,6 +244,32 @@ wheels = [
|
|
244
244
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
245
245
|
]
|
246
246
|
|
247
|
+
[[package]]
|
248
|
+
name = "levenshtein"
|
249
|
+
version = "0.27.1"
|
250
|
+
source = { registry = "https://pypi.org/simple" }
|
251
|
+
dependencies = [
|
252
|
+
{ name = "rapidfuzz" },
|
253
|
+
]
|
254
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7e/b3/b5f8011483ba9083a0bc74c4d58705e9cf465fbe55c948a1b1357d0a2aa8/levenshtein-0.27.1.tar.gz", hash = "sha256:3e18b73564cfc846eec94dd13fab6cb006b5d2e0cc56bad1fd7d5585881302e3", size = 382571 }
|
255
|
+
wheels = [
|
256
|
+
{ url = "https://files.pythonhosted.org/packages/c6/d3/30485fb9aee848542ee2d01aba85106a7f5da982ebeeffc619f70ea593c7/levenshtein-0.27.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ab00c2cae2889166afb7e1af64af2d4e8c1b126f3902d13ef3740df00e54032d", size = 173397 },
|
257
|
+
{ url = "https://files.pythonhosted.org/packages/df/9f/40a81c54cfe74b22737710e654bd25ad934a675f737b60b24f84099540e0/levenshtein-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c27e00bc7527e282f7c437817081df8da4eb7054e7ef9055b851fa3947896560", size = 155787 },
|
258
|
+
{ url = "https://files.pythonhosted.org/packages/df/98/915f4e24e21982b6eca2c0203546c160f4a83853fa6a2ac6e2b208a54afc/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5b07de42bfc051136cc8e7f1e7ba2cb73666aa0429930f4218efabfdc5837ad", size = 150013 },
|
259
|
+
{ url = "https://files.pythonhosted.org/packages/80/93/9b0773107580416b9de14bf6a12bd1dd2b2964f7a9f6fb0e40723e1f0572/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb11ad3c9dae3063405aa50d9c96923722ab17bb606c776b6817d70b51fd7e07", size = 181234 },
|
260
|
+
{ url = "https://files.pythonhosted.org/packages/91/b1/3cd4f69af32d40de14808142cc743af3a1b737b25571bd5e8d2f46b885e0/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c5986fb46cb0c063305fd45b0a79924abf2959a6d984bbac2b511d3ab259f3f", size = 183697 },
|
261
|
+
{ url = "https://files.pythonhosted.org/packages/bb/65/b691e502c6463f6965b7e0d8d84224c188aa35b53fbc85853c72a0e436c9/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75191e469269ddef2859bc64c4a8cfd6c9e063302766b5cb7e1e67f38cc7051a", size = 159964 },
|
262
|
+
{ url = "https://files.pythonhosted.org/packages/0f/c0/89a922a47306a475fb6d8f2ab08668f143d3dc7dea4c39d09e46746e031c/levenshtein-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51b3a7b2266933babc04e4d9821a495142eebd6ef709f90e24bc532b52b81385", size = 244759 },
|
263
|
+
{ url = "https://files.pythonhosted.org/packages/b4/93/30283c6e69a6556b02e0507c88535df9613179f7b44bc49cdb4bc5e889a3/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbac509794afc3e2a9e73284c9e3d0aab5b1d928643f42b172969c3eefa1f2a3", size = 1115955 },
|
264
|
+
{ url = "https://files.pythonhosted.org/packages/0b/cf/7e19ea2c23671db02fbbe5a5a4aeafd1d471ee573a6251ae17008458c434/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d68714785178347ecb272b94e85cbf7e638165895c4dd17ab57e7742d8872ec", size = 1400921 },
|
265
|
+
{ url = "https://files.pythonhosted.org/packages/e3/f7/fb42bfe2f3b46ef91f0fc6fa217b44dbeb4ef8c72a9c1917bbbe1cafc0f8/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8ee74ee31a5ab8f61cd6c6c6e9ade4488dde1285f3c12207afc018393c9b8d14", size = 1225037 },
|
266
|
+
{ url = "https://files.pythonhosted.org/packages/74/25/c86f8874ac7b0632b172d0d1622ed3ab9608a7f8fe85d41d632b16f5948e/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f2441b6365453ec89640b85344afd3d602b0d9972840b693508074c613486ce7", size = 1420601 },
|
267
|
+
{ url = "https://files.pythonhosted.org/packages/20/fe/ebfbaadcd90ea7dfde987ae95b5c11dc27c2c5d55a2c4ccbbe4e18a8af7b/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9be39640a46d8a0f9be729e641651d16a62b2c07d3f4468c36e1cc66b0183b9", size = 1188241 },
|
268
|
+
{ url = "https://files.pythonhosted.org/packages/2e/1a/aa6b07316e10781a6c5a5a8308f9bdc22213dc3911b959daa6d7ff654fc6/levenshtein-0.27.1-cp313-cp313-win32.whl", hash = "sha256:a520af67d976761eb6580e7c026a07eb8f74f910f17ce60e98d6e492a1f126c7", size = 88103 },
|
269
|
+
{ url = "https://files.pythonhosted.org/packages/9d/7b/9bbfd417f80f1047a28d0ea56a9b38b9853ba913b84dd5998785c5f98541/levenshtein-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:7dd60aa49c2d8d23e0ef6452c8329029f5d092f386a177e3385d315cabb78f2a", size = 100579 },
|
270
|
+
{ url = "https://files.pythonhosted.org/packages/8b/01/5f3ff775db7340aa378b250e2a31e6b4b038809a24ff0a3636ef20c7ca31/levenshtein-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:149cd4f0baf5884ac5df625b7b0d281721b15de00f447080e38f5188106e1167", size = 87933 },
|
271
|
+
]
|
272
|
+
|
247
273
|
[[package]]
|
248
274
|
name = "multidict"
|
249
275
|
version = "6.6.3"
|
@@ -508,6 +534,29 @@ wheels = [
|
|
508
534
|
{ url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747 },
|
509
535
|
]
|
510
536
|
|
537
|
+
[[package]]
|
538
|
+
name = "rapidfuzz"
|
539
|
+
version = "3.13.0"
|
540
|
+
source = { registry = "https://pypi.org/simple" }
|
541
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226 }
|
542
|
+
wheels = [
|
543
|
+
{ url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282 },
|
544
|
+
{ url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274 },
|
545
|
+
{ url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854 },
|
546
|
+
{ url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962 },
|
547
|
+
{ url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016 },
|
548
|
+
{ url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414 },
|
549
|
+
{ url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179 },
|
550
|
+
{ url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856 },
|
551
|
+
{ url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107 },
|
552
|
+
{ url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192 },
|
553
|
+
{ url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876 },
|
554
|
+
{ url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077 },
|
555
|
+
{ url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066 },
|
556
|
+
{ url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100 },
|
557
|
+
{ url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976 },
|
558
|
+
]
|
559
|
+
|
511
560
|
[[package]]
|
512
561
|
name = "rfc3986"
|
513
562
|
version = "1.5.0"
|
@@ -563,13 +612,14 @@ wheels = [
|
|
563
612
|
|
564
613
|
[[package]]
|
565
614
|
name = "tbr-deal-finder"
|
566
|
-
version = "0.1.
|
615
|
+
version = "0.1.7"
|
567
616
|
source = { editable = "." }
|
568
617
|
dependencies = [
|
569
618
|
{ name = "aiohttp" },
|
570
619
|
{ name = "audible" },
|
571
620
|
{ name = "click" },
|
572
621
|
{ name = "duckdb" },
|
622
|
+
{ name = "levenshtein" },
|
573
623
|
{ name = "pandas" },
|
574
624
|
{ name = "questionary" },
|
575
625
|
{ name = "tqdm" },
|
@@ -582,6 +632,7 @@ requires-dist = [
|
|
582
632
|
{ name = "audible", specifier = "==0.8.2" },
|
583
633
|
{ name = "click", specifier = ">=8.2.1" },
|
584
634
|
{ name = "duckdb", specifier = ">=1.3.2" },
|
635
|
+
{ name = "levenshtein", specifier = ">=0.27.1" },
|
585
636
|
{ name = "pandas", specifier = ">=2.3.1" },
|
586
637
|
{ name = "questionary", specifier = ">=2.1.0" },
|
587
638
|
{ name = "tqdm", specifier = ">=4.67.1" },
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_active_deals.sql
RENAMED
File without changes
|
{tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_deals_found_at.sql
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|