Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 97 additions & 7 deletions home_library/providers/ddg.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
и пытается извлечь название и автора из заголовков.
"""

import html
import logging
import re
from http import HTTPStatus
Expand All @@ -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<author>[А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2}?)(?=\s+(?:издательство|isbn|категория)\b|[.,;]|$)",
re.IGNORECASE,
)
AMAZON_AUTHOR_PATTERN = re.compile(
r":\s*97[89]\d{10}\s*:\s*(?P<author>[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 от шумовых суффиксов.
Expand Down Expand Up @@ -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.

Алгоритм:
Expand All @@ -73,6 +145,7 @@ def _extract_ddg_book(titles: list[str], isbn: str) -> BookRecord | None:
Args:
titles: Список заголовков результатов поиска.
isbn: ISBN для записи в результат.
snippets: Список сниппетов результатов поиска для fallback по автору.

Returns:
``BookRecord`` с извлечёнными данными или ``None``,
Expand Down Expand Up @@ -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: ищем автора из пар «автор — название»
Expand All @@ -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,
Expand Down Expand Up @@ -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"[^>]*>(.*?)</a',
resp.text,
re.DOTALL,
)
# Убираем HTML-теги из заголовков и ограничиваем количество
titles = [config.TITLE_HTML_PATTERN.sub("", title).strip() for title in titles_html[: config.SEARCH_LIMIT]]
return _extract_ddg_book(titles, isbn)
snippets_html = re.findall(
r'class="result__snippet"[^>]*>(.*?)</(?:a|div)>',
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(
Expand Down
86 changes: 86 additions & 0 deletions tests/test_home_library_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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",
Expand Down Expand Up @@ -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 = """
<html>
<body>
<a class="result__a" href="https://example.com">АНФ.От 0 до 5.Простые подсказки для умных родителей (0+) арт. 9785916717099</a>
<a class="result__snippet" href="https://example.com">Основные Тип нехудожественная книга ISBN 9785916717099 Категория воспитание, книги для родителей Автор Катчлоу Трейси Издательство Альпина</a>
</body>
</html>
"""
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(
Expand Down