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.
@@ -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)