titlemark 1.2.0__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.
- titlemark/__init__.py +8 -0
- titlemark/__main__.py +6 -0
- titlemark/ai.py +185 -0
- titlemark/cli.py +224 -0
- titlemark/extractors/__init__.py +56 -0
- titlemark/extractors/base.py +135 -0
- titlemark/extractors/djvu.py +100 -0
- titlemark/extractors/docx.py +63 -0
- titlemark/extractors/epub.py +63 -0
- titlemark/extractors/fb2.py +78 -0
- titlemark/extractors/mobi.py +99 -0
- titlemark/extractors/pdf.py +74 -0
- titlemark/models.py +48 -0
- titlemark/names.py +91 -0
- titlemark/naming.py +125 -0
- titlemark/ocr.py +81 -0
- titlemark/renamer.py +384 -0
- titlemark/signature.py +96 -0
- titlemark/textclean.py +344 -0
- titlemark/titlefinder.py +343 -0
- titlemark/tools.py +102 -0
- titlemark-1.2.0.dist-info/METADATA +234 -0
- titlemark-1.2.0.dist-info/RECORD +27 -0
- titlemark-1.2.0.dist-info/WHEEL +5 -0
- titlemark-1.2.0.dist-info/entry_points.txt +2 -0
- titlemark-1.2.0.dist-info/licenses/LICENSE +21 -0
- titlemark-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Экстрактор DJVU: аннотации ANTa + текстовый слой через djvulibre.
|
|
2
|
+
|
|
3
|
+
DjVu хранит текст в сжатых чанках TXTz (BZZ), извлечь их без djvulibre
|
|
4
|
+
нельзя; если утилиты `djvutxt`/`ddjvu` установлены (пакет djvulibre),
|
|
5
|
+
текст берётся из них, а для сканов работает OCR через ddjvu + tesseract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..tools import find_tool, tool_env
|
|
15
|
+
from .base import Extractor
|
|
16
|
+
|
|
17
|
+
_ANNOTATION_RES = [
|
|
18
|
+
(re.compile(r'\(\s*title\s+"([^"]*)"\s*\)', re.I), "title"),
|
|
19
|
+
(re.compile(r'\(\s*author\s+"([^"]*)"\s*\)', re.I), "author"),
|
|
20
|
+
(re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S), "title"),
|
|
21
|
+
(re.compile(r"<author[^>]*>(.*?)</author>", re.I | re.S), "author"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _chunks(data: bytes, pos: int, end: int):
|
|
26
|
+
"""Обойти чанки IFF: id(4) + длина(4, BE) + данные (с паддингом до чётности)."""
|
|
27
|
+
while pos + 8 <= end:
|
|
28
|
+
cid = data[pos:pos + 4]
|
|
29
|
+
length = int.from_bytes(data[pos + 4:pos + 8], "big")
|
|
30
|
+
body_start = pos + 8
|
|
31
|
+
body_end = body_start + length
|
|
32
|
+
if length < 0 or body_end > end:
|
|
33
|
+
return
|
|
34
|
+
yield cid, data[body_start:body_end]
|
|
35
|
+
pos = body_end + (length & 1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_annotations(data: bytes) -> dict[str, str]:
|
|
39
|
+
"""Найти первый чанк ANTa и вытащить из него title/author."""
|
|
40
|
+
|
|
41
|
+
def walk(buf: bytes, pos: int, end: int) -> dict[str, str]:
|
|
42
|
+
for cid, body in _chunks(buf, pos, end):
|
|
43
|
+
if cid == b"FORM" and len(body) >= 4:
|
|
44
|
+
found = walk(body, 4, len(body)) # вложенные чанки формы
|
|
45
|
+
if found:
|
|
46
|
+
return found
|
|
47
|
+
elif cid == b"ANTa":
|
|
48
|
+
text = body.decode("utf-8", "replace")
|
|
49
|
+
result: dict[str, str] = {}
|
|
50
|
+
for rx, key in _ANNOTATION_RES:
|
|
51
|
+
m = rx.search(text)
|
|
52
|
+
if m and not result.get(key):
|
|
53
|
+
result[key] = re.sub(r"<[^>]+>", "", m.group(1)).strip()
|
|
54
|
+
if result:
|
|
55
|
+
return result
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
start = 4 if data[:4] == b"AT&T" else 0
|
|
59
|
+
return walk(data, start, len(data))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run(cmd: list[str], timeout: int = 120) -> str:
|
|
63
|
+
try:
|
|
64
|
+
res = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
|
65
|
+
check=False, env=tool_env(cmd[0] if cmd else None))
|
|
66
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
67
|
+
return ""
|
|
68
|
+
return res.stdout.decode("utf-8", errors="replace")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def djvulibre_text(path: Path, max_pages: int = 3) -> str:
|
|
72
|
+
"""Текст первых страниц через djvutxt (djvulibre); "" если утилиты нет.
|
|
73
|
+
|
|
74
|
+
Разделитель страниц — form feed (\f).
|
|
75
|
+
"""
|
|
76
|
+
exe = find_tool("djvutxt")
|
|
77
|
+
if not exe:
|
|
78
|
+
return ""
|
|
79
|
+
out = _run([exe, str(path)])
|
|
80
|
+
if not out.strip():
|
|
81
|
+
return ""
|
|
82
|
+
pages = out.split("\f")
|
|
83
|
+
return "\n".join(pages[:max_pages])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DjvuExtractor(Extractor):
|
|
87
|
+
extensions = (".djvu",)
|
|
88
|
+
ocr_capable = True # рендер страниц через ddjvu + tesseract
|
|
89
|
+
|
|
90
|
+
def read_metadata(self, path: Path) -> tuple[str, str, str]:
|
|
91
|
+
try:
|
|
92
|
+
with open(path, "rb") as fh:
|
|
93
|
+
data = fh.read(4_000_000)
|
|
94
|
+
except OSError:
|
|
95
|
+
return "", "", ""
|
|
96
|
+
found = find_annotations(data)
|
|
97
|
+
return found.get("title", ""), found.get("author", ""), ""
|
|
98
|
+
|
|
99
|
+
def read_text(self, path: Path, max_pages: int = 3) -> str:
|
|
100
|
+
return djvulibre_text(path, max_pages=max_pages)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Экстрактор DOCX: docProps/core.xml + первые абзацы word/document.xml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from xml.etree import ElementTree
|
|
9
|
+
|
|
10
|
+
from .base import Extractor
|
|
11
|
+
|
|
12
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
13
|
+
_WT_RE = re.compile(r"<w:t[^>]*>(.*?)</w:t>", re.S)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _local(tag: str) -> str:
|
|
17
|
+
return tag.rsplit("}", 1)[-1].lower()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DocxExtractor(Extractor):
|
|
21
|
+
extensions = (".docx",)
|
|
22
|
+
|
|
23
|
+
def read_metadata(self, path: Path) -> tuple[str, str, str]:
|
|
24
|
+
try:
|
|
25
|
+
with zipfile.ZipFile(path) as zf:
|
|
26
|
+
try:
|
|
27
|
+
core = zf.read("docProps/core.xml")
|
|
28
|
+
except KeyError:
|
|
29
|
+
return "", "", ""
|
|
30
|
+
except (zipfile.BadZipFile, OSError):
|
|
31
|
+
return "", "", ""
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
root = ElementTree.fromstring(core)
|
|
35
|
+
except ElementTree.ParseError:
|
|
36
|
+
return "", "", ""
|
|
37
|
+
|
|
38
|
+
title = author = year = ""
|
|
39
|
+
for elem in root.iter():
|
|
40
|
+
name = _local(elem.tag)
|
|
41
|
+
text = (elem.text or "").strip()
|
|
42
|
+
if not text:
|
|
43
|
+
continue
|
|
44
|
+
if name == "title" and not title:
|
|
45
|
+
title = text
|
|
46
|
+
elif name == "creator" and not author:
|
|
47
|
+
author = text
|
|
48
|
+
elif name == "created":
|
|
49
|
+
m = re.search(r"\b(19|20)\d{2}\b", text)
|
|
50
|
+
if m:
|
|
51
|
+
year = m.group(0)
|
|
52
|
+
return title, author, year
|
|
53
|
+
|
|
54
|
+
def read_text(self, path: Path, max_pages: int = 3) -> str:
|
|
55
|
+
try:
|
|
56
|
+
with zipfile.ZipFile(path) as zf:
|
|
57
|
+
doc = zf.read("word/document.xml").decode("utf-8", "replace")
|
|
58
|
+
except (zipfile.BadZipFile, KeyError, OSError):
|
|
59
|
+
return ""
|
|
60
|
+
parts = _WT_RE.findall(doc)
|
|
61
|
+
if parts:
|
|
62
|
+
return " ".join(_TAG_RE.sub("", p) for p in parts[:200])
|
|
63
|
+
return _TAG_RE.sub(" ", doc[:20000])
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Экстрактор EPUB: метаданные из package document (.opf)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .base import Extractor
|
|
10
|
+
|
|
11
|
+
_DC_RE = {
|
|
12
|
+
"title": re.compile(r"<dc:title[^>]*>(.*?)</dc:title>", re.S | re.I),
|
|
13
|
+
"creator": re.compile(r"<dc:creator[^>]*>(.*?)</dc:creator>", re.S | re.I),
|
|
14
|
+
"date": re.compile(r"<dc:date[^>]*>(.*?)</dc:date>", re.S | re.I),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _strip_tags(text: str) -> str:
|
|
19
|
+
return re.sub(r"<[^>]+>", "", text).strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _opf_path(zf: zipfile.ZipFile) -> str | None:
|
|
23
|
+
# 1) правильный путь через container.xml
|
|
24
|
+
try:
|
|
25
|
+
container = zf.read("META-INF/container.xml").decode("utf-8", "replace")
|
|
26
|
+
m = re.search(r'full-path="([^"]+)"', container)
|
|
27
|
+
if m:
|
|
28
|
+
return m.group(1)
|
|
29
|
+
except KeyError:
|
|
30
|
+
pass
|
|
31
|
+
# 2) найти первый .opf
|
|
32
|
+
for name in zf.namelist():
|
|
33
|
+
if name.lower().endswith(".opf"):
|
|
34
|
+
return name
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EpubExtractor(Extractor):
|
|
39
|
+
extensions = (".epub",)
|
|
40
|
+
|
|
41
|
+
def read_metadata(self, path: Path) -> tuple[str, str, str]:
|
|
42
|
+
try:
|
|
43
|
+
with zipfile.ZipFile(path) as zf:
|
|
44
|
+
opf = _opf_path(zf)
|
|
45
|
+
if not opf:
|
|
46
|
+
return "", "", ""
|
|
47
|
+
data = zf.read(opf).decode("utf-8", "replace")
|
|
48
|
+
except (zipfile.BadZipFile, OSError):
|
|
49
|
+
return "", "", ""
|
|
50
|
+
|
|
51
|
+
def first(key: str) -> str:
|
|
52
|
+
m = _DC_RE[key].search(data)
|
|
53
|
+
return _strip_tags(m.group(1)) if m else ""
|
|
54
|
+
|
|
55
|
+
year = ""
|
|
56
|
+
m = re.search(r"\b(19|20)\d{2}\b", first("date"))
|
|
57
|
+
if m:
|
|
58
|
+
year = m.group(0)
|
|
59
|
+
return first("title"), first("creator"), year
|
|
60
|
+
|
|
61
|
+
def read_text(self, path: Path, max_pages: int = 3) -> str:
|
|
62
|
+
# метаданных EPUB почти всегда достаточно; текст не извлекаем
|
|
63
|
+
return ""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Экстрактор FB2: description/title-info/book-title и author."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from xml.etree import ElementTree
|
|
8
|
+
|
|
9
|
+
from .base import Extractor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _local(tag: str) -> str:
|
|
13
|
+
"""Тег без пространства имён (FB2 может использовать xmlns)."""
|
|
14
|
+
return tag.rsplit("}", 1)[-1].lower()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _text_of(elem) -> str:
|
|
18
|
+
return "".join(elem.itertext()).strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Fb2Extractor(Extractor):
|
|
22
|
+
extensions = (".fb2",)
|
|
23
|
+
|
|
24
|
+
def read_metadata(self, path: Path) -> tuple[str, str, str]:
|
|
25
|
+
try:
|
|
26
|
+
tree = ElementTree.parse(path)
|
|
27
|
+
except (ElementTree.ParseError, OSError):
|
|
28
|
+
return "", "", ""
|
|
29
|
+
|
|
30
|
+
title = author = year = ""
|
|
31
|
+
book_title_info = None
|
|
32
|
+
|
|
33
|
+
for elem in tree.iter():
|
|
34
|
+
name = _local(elem.tag)
|
|
35
|
+
if name == "title-info" and book_title_info is None:
|
|
36
|
+
book_title_info = elem
|
|
37
|
+
|
|
38
|
+
if book_title_info is not None:
|
|
39
|
+
for child in book_title_info:
|
|
40
|
+
cname = _local(child.tag)
|
|
41
|
+
if cname == "book-title":
|
|
42
|
+
title = _text_of(child)
|
|
43
|
+
elif cname == "author":
|
|
44
|
+
author = self._author_name(child)
|
|
45
|
+
elif cname == "date":
|
|
46
|
+
m = re.search(r"\b(19|20)\d{2}\b", _text_of(child))
|
|
47
|
+
if m:
|
|
48
|
+
year = m.group(0)
|
|
49
|
+
|
|
50
|
+
if not title:
|
|
51
|
+
for elem in tree.iter():
|
|
52
|
+
if _local(elem.tag) == "book-title":
|
|
53
|
+
title = _text_of(elem)
|
|
54
|
+
break
|
|
55
|
+
return title, author, year
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _author_name(author_elem) -> str:
|
|
59
|
+
parts: dict[str, str] = {}
|
|
60
|
+
for child in author_elem:
|
|
61
|
+
parts[_local(child.tag)] = _text_of(child)
|
|
62
|
+
first = parts.get("first-name", "")
|
|
63
|
+
middle = parts.get("middle-name", "")
|
|
64
|
+
last = parts.get("last-name", "")
|
|
65
|
+
nick = parts.get("nickname", "")
|
|
66
|
+
|
|
67
|
+
# инициалы: «Ю» + «А» + «Штюрмер» → «Штюрмер Ю. А.»
|
|
68
|
+
if first and len(first) <= 2 and last:
|
|
69
|
+
initials = first if first.endswith(".") else first + "."
|
|
70
|
+
if middle:
|
|
71
|
+
initials += " " + (middle if middle.endswith(".") else middle + ".")
|
|
72
|
+
return f"{last} {initials}".strip()
|
|
73
|
+
|
|
74
|
+
name = " ".join(p for p in (first, middle, last) if p).strip()
|
|
75
|
+
return name or nick
|
|
76
|
+
|
|
77
|
+
def read_text(self, path: Path, max_pages: int = 3) -> str:
|
|
78
|
+
return ""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Экстрактор MOBI: разбор PalmDB + EXTH-заголовков (без внешних библиотек)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import struct
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .base import Extractor
|
|
10
|
+
|
|
11
|
+
# типы EXTH-записей, которые нам нужны
|
|
12
|
+
_EXTH_AUTHOR = 100
|
|
13
|
+
_EXTH_PUBLISHED = 106
|
|
14
|
+
_EXTH_TITLE_UPDATED = 503
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _u2(data: bytes, off: int) -> int:
|
|
18
|
+
return struct.unpack_from(">H", data, off)[0]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _u4(data: bytes, off: int) -> int:
|
|
22
|
+
return struct.unpack_from(">I", data, off)[0]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _decode(raw: bytes, encoding: int) -> str:
|
|
26
|
+
enc = "utf-8" if encoding == 65001 else "cp1252"
|
|
27
|
+
try:
|
|
28
|
+
return raw.decode(enc)
|
|
29
|
+
except UnicodeDecodeError:
|
|
30
|
+
return raw.decode("cp1251", errors="replace")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_mobi(data: bytes) -> tuple[str, str, str]:
|
|
34
|
+
"""Вернуть (title, author, year) из байтов MOBI-файла."""
|
|
35
|
+
if len(data) < 0x50 or b"MOBI" not in data[:0x200]:
|
|
36
|
+
return "", "", ""
|
|
37
|
+
|
|
38
|
+
num_records = _u2(data, 0x4C)
|
|
39
|
+
if num_records == 0 or len(data) < 0x4E + 8:
|
|
40
|
+
return "", "", ""
|
|
41
|
+
rec0_off = _u4(data, 0x4E)
|
|
42
|
+
if rec0_off >= len(data):
|
|
43
|
+
return "", "", ""
|
|
44
|
+
rec0 = data[rec0_off:]
|
|
45
|
+
|
|
46
|
+
if len(rec0) < 0x5C or rec0[0x10:0x14] != b"MOBI":
|
|
47
|
+
return "", "", ""
|
|
48
|
+
encoding = _u4(rec0, 0x1C)
|
|
49
|
+
|
|
50
|
+
# полное название книги хранится в самом MOBI-заголовке
|
|
51
|
+
title = ""
|
|
52
|
+
fullname_off = _u4(rec0, 0x54)
|
|
53
|
+
fullname_len = _u4(rec0, 0x58)
|
|
54
|
+
if fullname_len and fullname_off + fullname_len <= len(rec0):
|
|
55
|
+
title = _decode(rec0[fullname_off:fullname_off + fullname_len], encoding).strip()
|
|
56
|
+
|
|
57
|
+
author = year = ""
|
|
58
|
+
exth_flags = _u4(rec0, 0x80) if len(rec0) >= 0x84 else 0
|
|
59
|
+
if exth_flags & 0x40:
|
|
60
|
+
header_len = _u4(rec0, 0x14)
|
|
61
|
+
exth_off = 0x10 + header_len
|
|
62
|
+
if rec0[exth_off:exth_off + 4] == b"EXTH":
|
|
63
|
+
count = _u4(rec0, exth_off + 8)
|
|
64
|
+
pos = exth_off + 12
|
|
65
|
+
for _ in range(count):
|
|
66
|
+
if pos + 8 > len(rec0):
|
|
67
|
+
break
|
|
68
|
+
etype = _u4(rec0, pos)
|
|
69
|
+
elen = _u4(rec0, pos + 4)
|
|
70
|
+
if elen < 8 or pos + elen > len(rec0):
|
|
71
|
+
break
|
|
72
|
+
value = _decode(rec0[pos + 8:pos + elen], encoding).strip("\x00").strip()
|
|
73
|
+
if etype == _EXTH_TITLE_UPDATED and value:
|
|
74
|
+
title = value
|
|
75
|
+
elif etype == _EXTH_AUTHOR and value:
|
|
76
|
+
author = value
|
|
77
|
+
elif etype == _EXTH_PUBLISHED and value:
|
|
78
|
+
m = re.search(r"\b(19|20)\d{2}\b", value)
|
|
79
|
+
if m:
|
|
80
|
+
year = m.group(0)
|
|
81
|
+
pos += elen
|
|
82
|
+
|
|
83
|
+
return title, author, year
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MobiExtractor(Extractor):
|
|
87
|
+
extensions = (".mobi", ".azw", ".prc")
|
|
88
|
+
|
|
89
|
+
def read_metadata(self, path: Path) -> tuple[str, str, str]:
|
|
90
|
+
try:
|
|
91
|
+
# заголовки лежат в первых килобайтах; читаем с запасом
|
|
92
|
+
with open(path, "rb") as fh:
|
|
93
|
+
data = fh.read(2_000_000)
|
|
94
|
+
except OSError:
|
|
95
|
+
return "", "", ""
|
|
96
|
+
return parse_mobi(data)
|
|
97
|
+
|
|
98
|
+
def read_text(self, path: Path, max_pages: int = 3) -> str:
|
|
99
|
+
return ""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Экстрактор PDF: poppler (pdfinfo/pdftotext) + OCR как запасной путь."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..tools import find_tool, run_tool
|
|
10
|
+
from .base import Extractor
|
|
11
|
+
|
|
12
|
+
_PDFINFO_RE = re.compile(r"^([^:]+):\s*(.*)$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache(maxsize=1)
|
|
16
|
+
def _tools() -> dict[str, str | None]:
|
|
17
|
+
return {
|
|
18
|
+
"pdfinfo": find_tool("pdfinfo"),
|
|
19
|
+
"pdftotext": find_tool("pdftotext"),
|
|
20
|
+
"pdftoppm": find_tool("pdftoppm"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run(cmd: list[str], timeout: int = 60) -> str:
|
|
25
|
+
if not cmd or not cmd[0]:
|
|
26
|
+
return ""
|
|
27
|
+
return run_tool(cmd[0], cmd[1:], timeout=timeout)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pdfinfo_fields(path: Path) -> dict[str, str]:
|
|
31
|
+
"""Свойства PDF в виде словаря {'Title': ..., 'Author': ...}."""
|
|
32
|
+
exe = _tools()["pdfinfo"]
|
|
33
|
+
if not exe:
|
|
34
|
+
return {}
|
|
35
|
+
out = _run([exe, str(path)])
|
|
36
|
+
fields: dict[str, str] = {}
|
|
37
|
+
for line in out.splitlines():
|
|
38
|
+
m = _PDFINFO_RE.match(line)
|
|
39
|
+
if m:
|
|
40
|
+
fields[m.group(1).strip()] = m.group(2).strip()
|
|
41
|
+
return fields
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _year_from_pdfinfo(fields: dict[str, str]) -> str:
|
|
45
|
+
for key in ("CreationDate", "ModDate"):
|
|
46
|
+
m = re.search(r"(19|20)\d{2}", fields.get(key, ""))
|
|
47
|
+
if m:
|
|
48
|
+
return m.group(0)
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PdfExtractor(Extractor):
|
|
53
|
+
extensions = (".pdf",)
|
|
54
|
+
ocr_capable = True
|
|
55
|
+
|
|
56
|
+
def read_metadata(self, path: Path) -> tuple[str, str, str]:
|
|
57
|
+
fields = pdfinfo_fields(path)
|
|
58
|
+
return (
|
|
59
|
+
fields.get("Title", ""),
|
|
60
|
+
fields.get("Author", ""),
|
|
61
|
+
_year_from_pdfinfo(fields),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def read_text(self, path: Path, max_pages: int = 3) -> str:
|
|
65
|
+
exe = _tools()["pdftotext"]
|
|
66
|
+
if not exe:
|
|
67
|
+
return ""
|
|
68
|
+
out = _run(
|
|
69
|
+
[exe, "-f", "1", "-l", str(max_pages), "-layout", "-q", str(path), "-"],
|
|
70
|
+
timeout=90,
|
|
71
|
+
)
|
|
72
|
+
# убрать символы, полученные из-за неверной кодировки ToUnicode,
|
|
73
|
+
# они всё равно не помогут в поиске заголовка
|
|
74
|
+
return out.replace("\x0c", "\n")
|
titlemark/models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Модели данных проекта titlemark."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Source(str, Enum):
|
|
11
|
+
"""Откуда получены данные о документе."""
|
|
12
|
+
|
|
13
|
+
METADATA = "metadata" # свойства/метаданные файла
|
|
14
|
+
TEXT = "text" # текстовый слой документа
|
|
15
|
+
OCR = "ocr" # OCR отсканированных страниц
|
|
16
|
+
AI = "ai" # vision-LLM по обложке (--ai)
|
|
17
|
+
FILENAME = "filename" # разбор текущего имени файла
|
|
18
|
+
NONE = "none" # ничего не найдено
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TextQuality(str, Enum):
|
|
22
|
+
"""Качество извлечённого текста."""
|
|
23
|
+
|
|
24
|
+
GOOD = "good"
|
|
25
|
+
GARBAGE = "garbage" # кракозябры из-за нестандартной кодировки шрифтов
|
|
26
|
+
EMPTY = "empty" # текстового слоя нет (скан)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DocumentInfo:
|
|
31
|
+
"""Результат извлечения сведений о документе."""
|
|
32
|
+
|
|
33
|
+
path: Path
|
|
34
|
+
format: str = ""
|
|
35
|
+
title: str = ""
|
|
36
|
+
author: str = ""
|
|
37
|
+
year: str = ""
|
|
38
|
+
title_source: Source = Source.NONE
|
|
39
|
+
author_source: Source = Source.NONE
|
|
40
|
+
warnings: list[str] = field(default_factory=list)
|
|
41
|
+
# отладочная информация для --verbose
|
|
42
|
+
text_snippet: str = ""
|
|
43
|
+
title_candidates: list[tuple[str, float]] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def resolved(self) -> bool:
|
|
47
|
+
"""Есть ли хоть какой-то полезный результат."""
|
|
48
|
+
return bool(self.title or self.author)
|
titlemark/names.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Распознавание имён людей в строках титульных страниц.
|
|
2
|
+
|
|
3
|
+
Заголовок «DARK MATTER» и подпись автора «BLAKE CROUCH» на обложке
|
|
4
|
+
выглядят одинаково (2 слова ЗАГЛАВНЫМИ). Разделяем их по словарю
|
|
5
|
+
распространённых имён: если одно из слов строки — известное имя,
|
|
6
|
+
строка скорее подпись автора.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_RU_FIRST_NAMES = frozenset("""
|
|
14
|
+
александр алексей алина алла анастасия андрей анна антон аркадий артур
|
|
15
|
+
борис богдан вадим валерий валерия варвара василий вера вероника виктор
|
|
16
|
+
виктория виталий владимир владислав галина геннадий георгий григорий дарья
|
|
17
|
+
денис дмитрий ева евгений евгения егор екатерина елена елизавета жанна
|
|
18
|
+
зинаида игорь иван илья инна ирина кирилл константин ксения лариса лев
|
|
19
|
+
леонид лидия любовь людмила маргарита марина мария михаил
|
|
20
|
+
надежда наталья никита николай нина оксана олег ольга павел пётр петр
|
|
21
|
+
полина роман руслан светлана семён сеня сергей софия софья станислав
|
|
22
|
+
степан тамара тарас татьяна тимур тимофей ульяна фёдор федор филипп
|
|
23
|
+
эдуард юлия юрий яков яна ярослав джоан джоанна джордж
|
|
24
|
+
# транслитерации частых английских имён (книги в переводе)
|
|
25
|
+
уильям джон джеймс роберт майкл дэвид эдвард томас ричард чарльз
|
|
26
|
+
дэниел дэниэл мэтью мартин брайан брюс винсент гарри герберт деннис
|
|
27
|
+
джеральд джозеф джонатан дуглас итан кеннет кевин лоуренс леонард
|
|
28
|
+
малкольм николас марк оливер патрик питер рональд стэнли стивен
|
|
29
|
+
теренс теодор франклин фредерик гарольд эндрю альберт арчибальд гас
|
|
30
|
+
элизабет дженнифер джессика кэтрин кэти мэри сара джейн лора эмма
|
|
31
|
+
линда маргарет моника нэнси патриция ребекка розмари сьюзан тереза
|
|
32
|
+
алиса аманда барбара бренда дебора диана дорис джудит кэрол
|
|
33
|
+
натали хелен хизер шерон ширли дебби
|
|
34
|
+
""".split())
|
|
35
|
+
|
|
36
|
+
_EN_FIRST_NAMES = frozenset("""
|
|
37
|
+
aaron adam alan albert alex alexander alice amanda amy andrew angela
|
|
38
|
+
anna anthony arthur audrey barbara barry benjamin bernard betty beverly
|
|
39
|
+
blake brandon brenda brian bruce bryan carol carter catherine cathy
|
|
40
|
+
charles charlotte cheryl chris christian christina christine christopher
|
|
41
|
+
cindy claire clarence clifford colin colleen connor cody craig cynthia
|
|
42
|
+
dale daniel danielle darren david dean deborah debra dennis derek diana
|
|
43
|
+
diane donald donna doris dorothy douglas dylan earl edgar edith edward
|
|
44
|
+
edwin eleanor elena elizabeth ellie ellen emily emma eric erica ernest
|
|
45
|
+
esther eugene evan evelyn fiona florence frances frank franklin
|
|
46
|
+
frederick gabriel gary george gerald gina gilbert glenn grace gregory
|
|
47
|
+
hannah harold harriet harry harvey heather helen henry herbert howard
|
|
48
|
+
hugh ian irene isaac isabel ivan jack jacob james jane janet janice
|
|
49
|
+
jared jason jay jean jeffrey jennifer jeremy jerry jessica jill joan
|
|
50
|
+
joanne joe joel john johnny jonathan jordan joseph joshua joyce judith
|
|
51
|
+
julia julian julie justin karen karl katherine kathleen kathryn kathy
|
|
52
|
+
keith kelly kenneth kevin kimberly kurt kyle larry laura lauren lawrence
|
|
53
|
+
leonard leslie lewis liam linda lisa lloyd logan lois louis louise lucas
|
|
54
|
+
luke lydia lynn madison malcolm marcus margaret maria marie marilyn mark
|
|
55
|
+
martha martin mary matthew maureen max megan melanie melissa michael
|
|
56
|
+
michelle mildred monica natalie nathan neil nelson nicholas nicole nigel
|
|
57
|
+
nina nora norman oliver oscar owen pamela patricia patrick paul paula
|
|
58
|
+
pedro peggy peter philip phoebe phyllis quentin rachel ralph randall
|
|
59
|
+
raymond rebecca regina richard rita robert roberta robin rodney roger
|
|
60
|
+
roland ronald rosa rose ross roy russell ruth ryan sabrina samantha
|
|
61
|
+
samuel sandra sarah scott sean seth sharon sheila shirley sidney simon
|
|
62
|
+
sophia spencer stanley stephanie stephen steven stuart susan sylvia
|
|
63
|
+
tamara tanya teresa thomas timothy tina todd travis trevor tyler valerie
|
|
64
|
+
vanessa vera vernon veronica victor victoria vincent virginia vivian
|
|
65
|
+
walter wanda warren wayne wendy wesley william willie zachary zoe
|
|
66
|
+
""".split())
|
|
67
|
+
|
|
68
|
+
_FIRST_NAMES = _RU_FIRST_NAMES | _EN_FIRST_NAMES
|
|
69
|
+
|
|
70
|
+
_WORDS_RE = re.compile(r"[^\s,]+")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_person_name(text: str) -> bool:
|
|
74
|
+
"""Похожа ли строка на имя человека: «АРТУР ДУМЧЕВ», «by Jane Smith».
|
|
75
|
+
|
|
76
|
+
Правила: 2–3 слова, среди них есть известное имя на первой или
|
|
77
|
+
последней позиции, остальные слова — инициалы или с заглавной буквы.
|
|
78
|
+
"""
|
|
79
|
+
t = text.strip().strip(",;:-–—")
|
|
80
|
+
words = [w.strip(".,'’\"()") for w in _WORDS_RE.findall(t)]
|
|
81
|
+
if not (2 <= len(words) <= 3) or any(not w for w in words):
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
known_positions = [i for i, w in enumerate(words) if w.lower() in _FIRST_NAMES]
|
|
85
|
+
if not known_positions:
|
|
86
|
+
return False
|
|
87
|
+
# имя должно быть первым или последним словом («ЧТО БУДЕТ» — не имя)
|
|
88
|
+
if not any(i in (0, len(words) - 1) for i in known_positions):
|
|
89
|
+
return False
|
|
90
|
+
# остальные слова: инициалы («В.») или слова с заглавной буквы
|
|
91
|
+
return all(len(w) <= 2 or w[0].isupper() for w in words)
|