diff --git a/home_library/providers/ddg.py b/home_library/providers/ddg.py index c0e3aa0..e613c99 100644 --- a/home_library/providers/ddg.py +++ b/home_library/providers/ddg.py @@ -4,6 +4,7 @@ и пытается извлечь название и автора из заголовков. """ +import html import logging import re from http import HTTPStatus @@ -14,9 +15,27 @@ from home_library.domain.models import BookRecord MIN_MEANINGFUL_TITLE_LENGTH = 3 +AUTHOR_NAME_PARTS_COUNT = 2 logger = logging.getLogger(__name__) +ALPINA_SERIES_PREFIX_PATTERN = re.compile(r"^АНФ\.", re.IGNORECASE) +MARKETPLACE_BOOK_PREFIX_PATTERN = re.compile( + r"^Нехудожественная книга\s+[А-ЯЁA-Za-z-]+\s+", + re.IGNORECASE, +) +AGE_MARK_PATTERN = re.compile(r"\s*\(\d+\+\)") +ARTICLE_SUFFIX_PATTERN = re.compile(r"\s+арт\.\s*\d{10,13}\s*$", re.IGNORECASE) +EDITION_SUFFIX_PATTERN = re.compile(r"\.\s*\d+-е изд\..*$", re.IGNORECASE) +AUTHOR_LABEL_PATTERN = re.compile( + r"\bавтор\s+(?P[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2}?)(?=\s+(?:издательство|isbn|категория)\b|[.,;]|$)", + re.IGNORECASE, +) +AMAZON_AUTHOR_PATTERN = re.compile( + r":\s*97[89]\d{10}\s*:\s*(?P[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){1,2})\s*:\s*Books", +) +TITLE_NOISE_PATTERN = re.compile(r"/|\bизд\b", re.IGNORECASE) + def _clean_ddg_title(raw_title: str) -> str: """Очищает заголовок результата DuckDuckGo от шумовых суффиксов. @@ -56,11 +75,64 @@ def _clean_ddg_title(raw_title: str) -> str: cleaned_title = cleaned_title.strip(config.STRIP_CHARS) # Убираем доменный префикс вроде "ozon.ru: " cleaned_title = config.TITLE_PREFIX_PATTERN.sub("", cleaned_title) + cleaned_title = ALPINA_SERIES_PREFIX_PATTERN.sub("", cleaned_title) + cleaned_title = MARKETPLACE_BOOK_PREFIX_PATTERN.sub("", cleaned_title) + cleaned_title = AGE_MARK_PATTERN.sub("", cleaned_title) + cleaned_title = ARTICLE_SUFFIX_PATTERN.sub("", cleaned_title) + cleaned_title = EDITION_SUFFIX_PATTERN.sub("", cleaned_title) # Убираем завершающие многоточия "..." - return config.TITLE_SUFFIX_PATTERN.sub("", cleaned_title) + cleaned_title = config.TITLE_SUFFIX_PATTERN.sub("", cleaned_title) + return cleaned_title.strip(config.STRIP_CHARS) + + +def _normalize_author(author: str, *, from_author_label: bool = False) -> str: + """Нормализует автора, извлечённого из выдачи DuckDuckGo.""" + normalized_author = html.unescape(author).strip(config.STRIP_CHARS + ":;,") + if not from_author_label: + return normalized_author + + parts = normalized_author.split() + if len(parts) != AUTHOR_NAME_PARTS_COUNT: + return normalized_author + if not all(config.CYRILLIC_PATTERN.search(part) for part in parts): + return normalized_author + if any("." in part for part in parts): + return normalized_author + + # После метки author имя часто приходит как "Фамилия Имя". + return f"{parts[1]} {parts[0]}" + + +def _extract_author_from_snippets(snippets: list[str]) -> str: + """Пытается извлечь автора из сниппетов результатов DuckDuckGo.""" + for raw_snippet in snippets: + snippet = html.unescape(raw_snippet) + author_match = AUTHOR_LABEL_PATTERN.search(snippet) + if author_match: + return _normalize_author( + author_match.group("author"), + from_author_label=True, + ) + + for raw_snippet in snippets: + snippet = html.unescape(raw_snippet) + author_match = AMAZON_AUTHOR_PATTERN.search(snippet) + if author_match: + return _normalize_author(author_match.group("author")) + + return "" + +def _title_noise_score(title: str) -> int: + """Возвращает грубую оценку шумности заголовка из поисковой выдачи.""" + return len(TITLE_NOISE_PATTERN.findall(title)) -def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: + +def _extract_ddg_book( + titles: list[str], + isbn: str, + snippets: list[str] | None = None, +) -> BookRecord | None: """Пытается извлечь книгу из набора заголовков результатов DuckDuckGo. Алгоритм: @@ -73,6 +145,7 @@ def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: Args: titles: Список заголовков результатов поиска. isbn: ISBN для записи в результат. + snippets: Список сниппетов результатов поиска для fallback по автору. Returns: ``BookRecord`` с извлечёнными данными или ``None``, @@ -118,7 +191,11 @@ def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: lowered = [title.lower() for title in cleaned] best = max( cleaned, - key=lambda candidate: (lowered.count(candidate.lower()), len(candidate)), + key=lambda candidate: ( + lowered.count(candidate.lower()), + -_title_noise_score(candidate), + len(candidate), + ), ) # Шаг 4: ищем автора из пар «автор — название» @@ -130,6 +207,8 @@ def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None: # Если точного совпадения нет — берём первого автора из пар if not author and em_dash_parts: author = em_dash_parts[0][0] + if not author and snippets: + author = _extract_author_from_snippets(snippets) return BookRecord( title=best, @@ -176,15 +255,26 @@ async def fetch_from_ddg(isbn: str, client: httpx.AsyncClient) -> BookRecord | N ) return None - # Извлекаем текст заголовков из HTML результатов + # Извлекаем текст заголовков и сниппетов из HTML результатов. titles_html = re.findall( r'class="result__a"[^>]*>(.*?)]*>(.*?)', + resp.text, + re.DOTALL, + ) + titles = [ + html.unescape(config.TITLE_HTML_PATTERN.sub("", title)).strip() + for title in titles_html[: config.SEARCH_LIMIT] + ] + snippets = [ + html.unescape(config.TITLE_HTML_PATTERN.sub("", snippet)).strip() + for snippet in snippets_html[: config.SEARCH_LIMIT] + ] + return _extract_ddg_book(titles, isbn, snippets=snippets) except httpx.HTTPError as exc: logger.warning( diff --git a/tests/test_home_library_providers.py b/tests/test_home_library_providers.py index 7117348..0e47767 100644 --- a/tests/test_home_library_providers.py +++ b/tests/test_home_library_providers.py @@ -244,6 +244,25 @@ def test_strips_trailing_ellipsis(self): def test_strips_multiple_separators(self): assert ddg._clean_ddg_title("Война и мир | Литрес - Ozon") == "Война и мир" + def test_strips_alpina_series_article_and_age_marks(self): + assert ( + ddg._clean_ddg_title( + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + ) + == "От 0 до 5.Простые подсказки для умных родителей" + ) + + def test_strips_article_suffix_without_alpina_prefix(self): + assert ddg._clean_ddg_title("Просто книга арт. 9785916717099") == "Просто книга" + + def test_strips_marketplace_prefix_and_edition_suffix(self): + assert ( + ddg._clean_ddg_title( + "Нехудожественная книга Альпина От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т.", + ) + == "От 0 до 5: Простые подсказки для умных родителей" + ) + class TestExtractDdgBook: def test_with_author_via_emdash(self): @@ -267,6 +286,53 @@ def test_without_author(self): assert result.title == "Война и мир" assert result.author == "" + def test_extracts_author_from_snippet(self): + titles = [ + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд ...", + ] + snippets = [ + "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина", + ] + + result = ddg._extract_ddg_book(titles, "9785916717099", snippets=snippets) + + assert result is not None + assert result.title == "От 0 до 5.Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + + def test_prefers_cleaner_title_when_frequency_is_equal(self): + titles = [ + "Чистое название", + "Чистое название / служебный хвост изд ...", + ] + + result = ddg._extract_ddg_book(titles, "9785171234567") + + assert result is not None + assert result.title == "Чистое название" + + def test_extracts_specific_alpina_non_fiction_book(self): + titles = [ + "Amazon.com: Ot 0 do 5 : prostye podskazki dlya umnyh roditeley ...", + "От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т.", + "Нехудожественная книга Альпина От 0 до 5. Простые подсказки для умных ...", + "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд ...", + "АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099", + ] + snippets = [ + "Amazon.com: Ot 0 do 5 : prostye podskazki dlya umnyh roditeley: 9785916717099: Trejsi Katchlou: Books", + "Купить Учебники.Словари.Энциклопедии От 0 до 5: Простые подсказки для умных родителей. 3-е изд. Катчлоу Т. Катчлоу Трейси 9785916717099", + "Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина", + "От 0 до 5: Простые подсказки для умных родителей / Раннее развитие изд-во, Автор 0, книга, isbn:9785916717099,", + ] + + result = ddg._extract_ddg_book(titles, "9785916717099", snippets=snippets) + + assert result is not None + assert result.title == "От 0 до 5: Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + def test_non_cyrillic_filtered_returns_none(self): titles = [ "War and Peace - Amazon", @@ -301,6 +367,26 @@ async def test_success(self): assert result is not None assert result.title == "Война и мир" + @pytest.mark.asyncio + async def test_success_with_author_from_snippet(self): + html = """ + + + АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099 + Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина + + + """ + transport = httpx.MockTransport( + lambda req: httpx.Response(200, text=html), + ) + async with httpx.AsyncClient(transport=transport) as client: + result = await ddg.fetch_from_ddg("9785916717099", client) + + assert result is not None + assert result.title == "От 0 до 5.Простые подсказки для умных родителей" + assert result.author == "Трейси Катчлоу" + @pytest.mark.asyncio async def test_http_error_returns_none(self): transport = httpx.MockTransport(