-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathutils.py
More file actions
259 lines (214 loc) · 7.7 KB
/
Copy pathutils.py
File metadata and controls
259 lines (214 loc) · 7.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""
utils.py — Title cleaning, fuzzy matching, and multi-signal scoring.
Scoring signals:
title: 10 pts exact/fuzzy title match
title_clean: 8 pts cleaned title matched (only if raw didn't)
author: 5 pts full name match, or 3 pts last-name only
narrator: 7 pts narrator match (key for picking correct edition)
series: 3 pts series name match
detail: 2 pts result has description/genres (fetched from detail page)
Threshold for auto-pick: 8
"""
import re
# ============================================================
# Title Cleaning
# ============================================================
def clean_title(title: str) -> str:
"""
Strip numbering prefixes, series suffixes, edition markers, and subtitle noise.
Examples:
"Dune 20 - Dune" -> "Dune"
"MI 4 - City of Fallen Angels" -> "City of Fallen Angels"
"Oakleaf Bearers - Ranger's Apprentice Series, Book 4" -> "Oakleaf Bearers"
"The Hunger Games: Special Edition" -> "The Hunger Games"
"The Dark Tower VIII - The Wind Through The Key Hole" -> "The Wind Through The Key Hole"
"Harry Potter and the Order of the Phoenix (Full-Cast Edition)" -> "Harry Potter and the Order of the Phoenix"
"Dune: The Lady of Caladan" -> "Dune: The Lady of Caladan" (keeps meaningful subtitles)
"""
t = title
# Strip leading number/code prefixes: "Dune 20 - ", "01 - ", "MI 4 - "
t = re.sub(r'^[\w]*?\s*\d+\s*[-–—]\s*', '', t).strip()
# Strip trailing series info: "- Ranger's Apprentice Series, Book 11"
t = re.sub(r'\s*[-–—]\s*.*?[Ss]eries.*$', '', t).strip()
# Strip trailing "- Book X" / ", Book X"
t = re.sub(r'\s*[-–—,]\s*[Bb]ook\s+\d+.*$', '', t).strip()
# Strip trailing volume/part markers: "Vol. 1", "Volume 2", "Part 1", "Pt 1"
t = re.sub(r'\s*[-–—,:]?\s*(?:Vol(?:ume)?|Pt|Part)\.?\s*\d+.*$',
'', t, flags=re.IGNORECASE).strip()
# Strip edition markers in parens/brackets: "(Unabridged)", "[Full Cast Edition]"
t = re.sub(
r'\s*[\(\[][^)\]]*(?:Unabridged|Abridged|Edition|Version|Full[- ]Cast|Collector)[^)\]]*[\)\]]',
'', t, flags=re.IGNORECASE,
).strip()
# Strip colon-separated edition/format suffixes:
# "The Hunger Games: Special Edition" -> "The Hunger Games"
# But keep meaningful subtitles like "Dune: The Lady of Caladan"
_edition_words = (
r'(?:(?:\d+\w*\s+)?' # optional "75th " prefix
r'(?:Special|Deluxe|Collector\'?s?|Anniversary|Commemorative|Limited|Expanded|'
r'Complete|Definitive|Illustrated|Enhanced|Premium|Original|Classic|'
r'Revised|Updated|Extended|Remastered)\s*Edition'
r')'
)
t = re.sub(rf'\s*:\s*{_edition_words}.*$',
'', t, flags=re.IGNORECASE).strip()
# Strip "- A [Series] Novel" suffixes: "The Ballad of Songbirds and Snakes: A Hunger Games Novel"
t = re.sub(r'\s*:\s*[Aa]n?\s+.+?\s+[Nn]ovel\s*$', '', t).strip()
return t if t else title
# ============================================================
# Fuzzy Matching
# ============================================================
def normalize(s: str) -> str:
"""Lowercase, strip parens/brackets, non-alphanum, collapse whitespace."""
s = s.lower()
s = re.sub(r'\(.*?\)', '', s)
s = re.sub(r'\[.*?\]', '', s)
s = re.sub(r'[^a-z0-9\s]', '', s)
s = re.sub(r'\s+', ' ', s).strip()
return s
def _extract_numbers(s: str) -> set:
"""Extract all numbers (arabic and roman) from a string."""
roman_map = {
'i': 1, 'ii': 2, 'iii': 3, 'iv': 4, 'v': 5, 'vi': 6, 'vii': 7,
'viii': 8, 'ix': 9, 'x': 10, 'xi': 11, 'xii': 12, 'xiii': 13,
'xiv': 14, 'xv': 15, 'xvi': 16, 'xvii': 17, 'xviii': 18,
'xix': 19, 'xx': 20,
}
nums = set()
# Arabic numbers
for m in re.finditer(r'\b\d+\b', s):
nums.add(int(m.group()))
# Roman numerals (standalone words only)
for m in re.finditer(r'\b([ivxlc]+)\b', s.lower()):
val = roman_map.get(m.group())
if val:
nums.add(val)
return nums
def titles_match(a: str, b: str) -> bool:
"""Fuzzy check if two titles refer to the same book.
Key rule: if both titles contain sequence numbers (arabic or roman),
those numbers must match. This prevents "Dark Tower II" matching
"Dark Tower VIII" or "Dune 01" matching "Dune 03".
"""
na, nb = normalize(a), normalize(b)
if not na or not nb:
return False
if na == nb:
return True
# Number conflict check — if both have numbers, they must agree
nums_a = _extract_numbers(a)
nums_b = _extract_numbers(b)
if nums_a and nums_b and nums_a.isdisjoint(nums_b):
return False
if na in nb or nb in na:
return True
wa, wb = set(na.split()), set(nb.split())
if not wa or not wb:
return False
overlap = len(wa & wb) / min(len(wa), len(wb))
return overlap >= 0.6
def names_match(a: str, b: str) -> bool:
"""
Check if two person names refer to the same person.
Works for authors and narrators.
"""
if not a or not b:
return False
al, bl = a.lower().strip(), b.lower().strip()
if al in bl or bl in al:
return True
a_parts = al.split()
b_parts = bl.split()
if a_parts and b_parts and a_parts[-1] == b_parts[-1]:
return True
return False
# Backward compat alias
authors_match = names_match
# ============================================================
# Multi-Signal Scoring
# ============================================================
def score_result(
title: str,
author: str,
result: dict,
narrator: str = "",
series: str = "",
) -> dict:
"""
Score a single Audible result against the target book.
Returns dict with individual signal scores and total.
"""
signals = {
"title": 0,
"title_clean": 0,
"author": 0,
"narrator": 0,
"series": 0,
"detail": 0,
}
rt = result.get("title", "")
ra = result.get("author", "")
rn = result.get("narrator", "")
rs = result.get("series", "")
# --- Title ---
if titles_match(title, rt):
signals["title"] = 10
else:
cleaned = clean_title(title)
if cleaned != title and titles_match(cleaned, rt):
signals["title_clean"] = 8
# --- Author ---
if names_match(author, ra):
signals["author"] = 5
elif author and ra:
a_parts = author.lower().split()
r_parts = ra.lower().split()
if a_parts and r_parts and a_parts[-1] == r_parts[-1]:
signals["author"] = 3
# --- Narrator (key for correct edition) ---
if narrator and rn:
if names_match(narrator, rn):
signals["narrator"] = 7
# --- Series ---
if series and rs:
if titles_match(series, rs):
signals["series"] = 3
# --- Detail richness (prefer detail-fetched results) ---
if result.get("description") or result.get("genres"):
signals["detail"] = 2
signals["total"] = sum(signals.values())
return signals
def pick_best(
title: str,
author: str,
results: list,
threshold: int = 13,
narrator: str = "",
series: str = "",
) -> int | None:
"""
Score all results, return index of best if above threshold.
Default threshold 13 requires title match (10) + author match (3+).
Title alone (10) won't auto-pick — prevents wrong-author matches.
"""
if not results:
return None
scored = [
(i, score_result(title, author, r, narrator=narrator, series=series))
for i, r in enumerate(results)
]
scored.sort(key=lambda x: -x[1]["total"])
best_idx, best_signals = scored[0]
return best_idx if best_signals["total"] >= threshold else None
def is_confident(
title: str,
author: str,
result: dict,
threshold: int = 13,
narrator: str = "",
series: str = "",
) -> bool:
"""Check if a single result is a confident match."""
signals = score_result(title, author, result,
narrator=narrator, series=series)
return signals["total"] >= threshold