pdf-file-renamer 0.6.2__py3-none-any.whl → 0.6.3__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.
- pdf_file_renamer/infrastructure/doi/pdf2doi_extractor.py +139 -3
- {pdf_file_renamer-0.6.2.dist-info → pdf_file_renamer-0.6.3.dist-info}/METADATA +3 -2
- {pdf_file_renamer-0.6.2.dist-info → pdf_file_renamer-0.6.3.dist-info}/RECORD +6 -6
- {pdf_file_renamer-0.6.2.dist-info → pdf_file_renamer-0.6.3.dist-info}/WHEEL +0 -0
- {pdf_file_renamer-0.6.2.dist-info → pdf_file_renamer-0.6.3.dist-info}/entry_points.txt +0 -0
- {pdf_file_renamer-0.6.2.dist-info → pdf_file_renamer-0.6.3.dist-info}/licenses/LICENSE +0 -0
@@ -3,6 +3,7 @@
|
|
3
3
|
import asyncio
|
4
4
|
import contextlib
|
5
5
|
import re
|
6
|
+
from difflib import SequenceMatcher
|
6
7
|
from pathlib import Path
|
7
8
|
|
8
9
|
import pdf2doi
|
@@ -14,10 +15,18 @@ from pdf_file_renamer.domain.ports import DOIExtractor
|
|
14
15
|
class PDF2DOIExtractor(DOIExtractor):
|
15
16
|
"""Extract DOI from PDF files using pdf2doi library."""
|
16
17
|
|
17
|
-
def __init__(self) -> None:
|
18
|
-
"""
|
18
|
+
def __init__(self, validate_match: bool = True, similarity_threshold: float = 0.3) -> None:
|
19
|
+
"""
|
20
|
+
Initialize the PDF2DOI extractor.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
validate_match: Whether to validate that DOI metadata matches PDF content
|
24
|
+
similarity_threshold: Minimum similarity score (0-1) for title validation
|
25
|
+
"""
|
19
26
|
# Suppress pdf2doi verbose output
|
20
27
|
pdf2doi.config.set("verbose", False)
|
28
|
+
self.validate_match = validate_match
|
29
|
+
self.similarity_threshold = similarity_threshold
|
21
30
|
|
22
31
|
async def extract_doi(self, pdf_path: Path) -> DOIMetadata | None:
|
23
32
|
"""
|
@@ -91,7 +100,7 @@ class PDF2DOIExtractor(DOIExtractor):
|
|
91
100
|
# Extract publisher
|
92
101
|
publisher = metadata.get("publisher")
|
93
102
|
|
94
|
-
|
103
|
+
doi_metadata = DOIMetadata(
|
95
104
|
doi=identifier,
|
96
105
|
title=title,
|
97
106
|
authors=authors,
|
@@ -101,6 +110,16 @@ class PDF2DOIExtractor(DOIExtractor):
|
|
101
110
|
raw_bibtex=validation_info if validation_info else None,
|
102
111
|
)
|
103
112
|
|
113
|
+
# Validate that the DOI metadata matches the PDF content
|
114
|
+
if self.validate_match:
|
115
|
+
# Extract first page text from PDF to check for title match
|
116
|
+
pdf_text = await self._extract_pdf_first_page(pdf_path)
|
117
|
+
if not self._validate_doi_matches_pdf(doi_metadata, pdf_text):
|
118
|
+
# DOI doesn't match - likely a citation DOI, not the paper's DOI
|
119
|
+
return None
|
120
|
+
|
121
|
+
return doi_metadata
|
122
|
+
|
104
123
|
except Exception:
|
105
124
|
# Silently fail - DOI extraction is opportunistic
|
106
125
|
return None
|
@@ -158,3 +177,120 @@ class PDF2DOIExtractor(DOIExtractor):
|
|
158
177
|
]
|
159
178
|
|
160
179
|
return authors if authors else None
|
180
|
+
|
181
|
+
async def _extract_pdf_first_page(self, pdf_path: Path) -> str:
|
182
|
+
"""
|
183
|
+
Extract text from the first page of a PDF.
|
184
|
+
|
185
|
+
Args:
|
186
|
+
pdf_path: Path to PDF file
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
Text from first page (empty string if extraction fails)
|
190
|
+
"""
|
191
|
+
try:
|
192
|
+
import fitz # PyMuPDF
|
193
|
+
|
194
|
+
loop = asyncio.get_event_loop()
|
195
|
+
|
196
|
+
def extract() -> str:
|
197
|
+
with fitz.open(pdf_path) as doc:
|
198
|
+
if len(doc) > 0:
|
199
|
+
return doc[0].get_text()
|
200
|
+
return ""
|
201
|
+
|
202
|
+
return await loop.run_in_executor(None, extract)
|
203
|
+
except Exception:
|
204
|
+
return ""
|
205
|
+
|
206
|
+
def _validate_doi_matches_pdf(self, doi_metadata: DOIMetadata, pdf_text: str) -> bool:
|
207
|
+
"""
|
208
|
+
Validate that DOI metadata matches the PDF content.
|
209
|
+
|
210
|
+
This checks if the title from the DOI metadata appears in the PDF text
|
211
|
+
(particularly the first page, where the title should be).
|
212
|
+
|
213
|
+
Args:
|
214
|
+
doi_metadata: DOI metadata to validate
|
215
|
+
pdf_text: Text from PDF first page (not full document!)
|
216
|
+
|
217
|
+
Returns:
|
218
|
+
True if metadata appears to match PDF, False otherwise
|
219
|
+
"""
|
220
|
+
if not doi_metadata.title or not pdf_text:
|
221
|
+
# If we can't validate, assume it's valid (fail open)
|
222
|
+
return True
|
223
|
+
|
224
|
+
# Normalize text for comparison
|
225
|
+
pdf_text_lower = pdf_text.lower()
|
226
|
+
title_lower = doi_metadata.title.lower()
|
227
|
+
|
228
|
+
# Check if the full title appears in the PDF text
|
229
|
+
if title_lower in pdf_text_lower:
|
230
|
+
return True
|
231
|
+
|
232
|
+
# Check similarity using SequenceMatcher on first ~300 chars (title area)
|
233
|
+
# Most paper titles appear in the first few hundred characters
|
234
|
+
title_area = pdf_text_lower[:300]
|
235
|
+
similarity = SequenceMatcher(None, title_lower, title_area).ratio()
|
236
|
+
|
237
|
+
if similarity >= self.similarity_threshold:
|
238
|
+
return True
|
239
|
+
|
240
|
+
# Check if significant words from title appear in the title area ONLY
|
241
|
+
# This prevents matching citation DOIs from the references section
|
242
|
+
title_words = self._extract_significant_words(title_lower)
|
243
|
+
if not title_words:
|
244
|
+
return True # Can't validate, fail open
|
245
|
+
|
246
|
+
# Require at least 70% of significant words to appear in the title area
|
247
|
+
matches = sum(1 for word in title_words if word in title_area)
|
248
|
+
match_ratio = matches / len(title_words)
|
249
|
+
|
250
|
+
return match_ratio >= 0.7
|
251
|
+
|
252
|
+
def _extract_significant_words(self, text: str) -> list[str]:
|
253
|
+
"""
|
254
|
+
Extract significant words from text (removing common words).
|
255
|
+
|
256
|
+
Args:
|
257
|
+
text: Input text
|
258
|
+
|
259
|
+
Returns:
|
260
|
+
List of significant words
|
261
|
+
"""
|
262
|
+
# Common words to skip
|
263
|
+
stop_words = {
|
264
|
+
"a",
|
265
|
+
"an",
|
266
|
+
"the",
|
267
|
+
"and",
|
268
|
+
"or",
|
269
|
+
"but",
|
270
|
+
"in",
|
271
|
+
"on",
|
272
|
+
"at",
|
273
|
+
"to",
|
274
|
+
"for",
|
275
|
+
"of",
|
276
|
+
"with",
|
277
|
+
"by",
|
278
|
+
"from",
|
279
|
+
"as",
|
280
|
+
"is",
|
281
|
+
"was",
|
282
|
+
"are",
|
283
|
+
"were",
|
284
|
+
"been",
|
285
|
+
"be",
|
286
|
+
"this",
|
287
|
+
"that",
|
288
|
+
"these",
|
289
|
+
"those",
|
290
|
+
}
|
291
|
+
|
292
|
+
# Extract words (alphanumeric only)
|
293
|
+
words = re.findall(r"\b\w+\b", text.lower())
|
294
|
+
|
295
|
+
# Filter stop words and short words
|
296
|
+
return [w for w in words if w not in stop_words and len(w) > 3]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pdf-file-renamer
|
3
|
-
Version: 0.6.
|
3
|
+
Version: 0.6.3
|
4
4
|
Summary: Intelligent PDF renaming using LLMs with DOI-based naming and interactive workflow
|
5
5
|
Project-URL: Homepage, https://github.com/nostoslabs/pdf-renamer
|
6
6
|
Project-URL: Repository, https://github.com/nostoslabs/pdf-renamer
|
@@ -285,7 +285,8 @@ The tool uses a multi-strategy approach to generate accurate filenames:
|
|
285
285
|
|
286
286
|
1. **DOI Detection** (for academic papers)
|
287
287
|
- Searches PDF for DOI identifiers using [pdf2doi](https://github.com/MicheleCotrufo/pdf2doi)
|
288
|
-
-
|
288
|
+
- **Validates DOI metadata** against PDF content to prevent citation DOI mismatches
|
289
|
+
- If found and validated, queries authoritative metadata (title, authors, year, journal)
|
289
290
|
- Generates filename with **very high confidence** from validated metadata
|
290
291
|
- **Saves API costs** - no LLM call needed for papers with DOIs
|
291
292
|
|
@@ -10,7 +10,7 @@ pdf_file_renamer/domain/ports.py,sha256=ebOcHptiOK119NCmIwM32_fbRK5xkZP9K67vjL-4
|
|
10
10
|
pdf_file_renamer/infrastructure/__init__.py,sha256=C3ZQ7WCPCa6PMfP00lu4wqb0r57GVyDdiD5EL2DhCeY,187
|
11
11
|
pdf_file_renamer/infrastructure/config.py,sha256=baNL5_6_NNiS50ZNdql7fDwQbeAwf6f58HGYIWFQxQQ,2464
|
12
12
|
pdf_file_renamer/infrastructure/doi/__init__.py,sha256=8N9ZEwfG7q5xomzh187YtP8t4CfEBHM334xNRblPeuI,153
|
13
|
-
pdf_file_renamer/infrastructure/doi/pdf2doi_extractor.py,sha256=
|
13
|
+
pdf_file_renamer/infrastructure/doi/pdf2doi_extractor.py,sha256=1tQ7fQF3TPxUZ7By9dzKz4LAfE8TPyjlvt8lACqGiLk,9551
|
14
14
|
pdf_file_renamer/infrastructure/llm/__init__.py,sha256=ToB8__mHvXwaIukGKPEAQ8SeC4ZLiH4auZI1P1yH5PQ,159
|
15
15
|
pdf_file_renamer/infrastructure/llm/pydantic_ai_provider.py,sha256=kVsmj0NIawkj-1WWM0hZXbsNH09GabVZm9HPlYsxGuo,9217
|
16
16
|
pdf_file_renamer/infrastructure/pdf/__init__.py,sha256=uMHqxSXNLZH5WH_e1kXrp9m7uTqPkiI2hXjNo6rCRoo,368
|
@@ -20,8 +20,8 @@ pdf_file_renamer/infrastructure/pdf/pymupdf_extractor.py,sha256=C61udZCqGqiVx7T0
|
|
20
20
|
pdf_file_renamer/presentation/__init__.py,sha256=1VR44GoPGTixk3hG5YzhGyQf7a4BTKsJBd2VP3rHcFM,211
|
21
21
|
pdf_file_renamer/presentation/cli.py,sha256=0t_59-utRWLNCYjFetU0ZHoF1DPTjdNiWM9Au0jFaOg,8013
|
22
22
|
pdf_file_renamer/presentation/formatters.py,sha256=8Vz95QupJKkPgPgRyMVmA_gxRWG5vfxdnSd7Czovlrg,8946
|
23
|
-
pdf_file_renamer-0.6.
|
24
|
-
pdf_file_renamer-0.6.
|
25
|
-
pdf_file_renamer-0.6.
|
26
|
-
pdf_file_renamer-0.6.
|
27
|
-
pdf_file_renamer-0.6.
|
23
|
+
pdf_file_renamer-0.6.3.dist-info/METADATA,sha256=ywxT5kRE2VGcv1HUuwvqrAeaVw7ksYsn6Y6MTa5hShA,16952
|
24
|
+
pdf_file_renamer-0.6.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
25
|
+
pdf_file_renamer-0.6.3.dist-info/entry_points.txt,sha256=0fEGYy60chGE9rECWeCVPxjxzz6vMtIAYdFvmH7xzbw,63
|
26
|
+
pdf_file_renamer-0.6.3.dist-info/licenses/LICENSE,sha256=_w08V08WgoMpDMlGNlkIatC5QfQ_Ds_rXOBM8pl7ffE,1068
|
27
|
+
pdf_file_renamer-0.6.3.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|