Files
parser-v1/normalization.py
T
Aks f78f35fb3f init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:56:06 +03:00

209 lines
8.9 KiB
Python

"""Нормализация телефонов, доменов, чисел.
Принцип: парсер возвращает грязные данные → нормализация делает их каноничными
для дедупликации.
"""
import re
from typing import Optional
import phonenumbers
def normalize_phone(raw: str | None) -> Optional[str]:
"""Любой формат RU-телефона → E.164 (+7XXXXXXXXXX) либо None.
>>> normalize_phone("+7 (495) 258-08-88")
'+74952580888'
>>> normalize_phone("8 800 700-41-17")
'+78007004117'
>>> normalize_phone("ерунда")
"""
if not raw:
return None
# Сначала через phonenumbers — он умеет в большинство форматов
try:
parsed = phonenumbers.parse(raw, "RU")
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException:
pass
# Fallback: собрать E.164 из голых цифр — НО обязательно провалидировать через
# phonenumbers. Без этого пролезает мусор, который раньше тащил body-скан Я.Карт:
# placeholder-номера (+73333333333) и несуществующие коды (+7393...).
digits = re.sub(r"\D", "", raw)
candidate = None
if len(digits) == 11 and digits[0] in ("7", "8"):
candidate = "+7" + digits[1:]
elif len(digits) == 10:
candidate = "+7" + digits
if candidate:
try:
parsed = phonenumbers.parse(candidate, "RU")
if phonenumbers.is_valid_number(parsed):
return candidate
except phonenumbers.NumberParseException:
pass
return None
def phone_dedup_key(phone_e164: str | None) -> Optional[str]:
"""Из +74952580888 → '4952580888' (10 цифр для дедупликации)."""
if not phone_e164:
return None
digits = re.sub(r"\D", "", phone_e164)
return digits[-10:] if len(digits) >= 10 else None
def is_valid_inn(inn: str | None) -> bool:
"""Проверка ИНН по контрольным цифрам РФ (10 — юр.лицо, 12 — ИП/физлицо).
Отсекает мусор/заглушки (напр. 888800000099 из footer сайта), которые
проходят по длине, но не по контрольной сумме.
"""
if not inn or not inn.isdigit():
return False
d = [int(x) for x in inn]
if len(d) == 10:
k = [2, 4, 10, 3, 5, 9, 4, 6, 8]
return d[9] == (sum(k[i] * d[i] for i in range(9)) % 11) % 10
if len(d) == 12:
k1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
k2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
c1 = (sum(k1[i] * d[i] for i in range(10)) % 11) % 10
c2 = (sum(k2[i] * d[i] for i in range(11)) % 11) % 10
return d[10] == c1 and d[11] == c2
return False
def is_valid_ogrn(ogrn: str | None) -> bool:
"""Проверка ОГРН/ОГРНИП по контрольной цифре (13 — юр.лицо, 15 — ИП).
Отсекает фейк-ОГРН с footer'ов сайтов (проходят по длине, не по контролю).
"""
if not ogrn or not ogrn.isdigit():
return False
if len(ogrn) == 13:
return int(ogrn[12]) == (int(ogrn[:12]) % 11) % 10
if len(ogrn) == 15:
return int(ogrn[14]) == (int(ogrn[:14]) % 13) % 10
return False
def normalize_domain(url: str | None) -> Optional[str]:
"""https://www.example.ru/path?q=1 → 'example.ru'.
>>> normalize_domain("https://www.gvidon.wrf.su/?utm_campaign=x")
'gvidon.wrf.su'
>>> normalize_domain("karavaevi.ru")
'karavaevi.ru'
"""
if not url:
return None
s = url.strip().lower()
s = re.sub(r"^https?://", "", s)
s = re.sub(r"^www\.", "", s)
s = s.split("/")[0].split("?")[0]
return s or None
def parse_rating(raw: str | None) -> Optional[float]:
"""'5,0' / '4.6 (123 оценки)' → 5.0 / 4.6"""
if not raw:
return None
m = re.search(r"(\d[.,]?\d*)", raw)
if not m:
return None
try:
return float(m.group(1).replace(",", "."))
except ValueError:
return None
def parse_reviews_count(raw: str | None) -> int:
"""'5819 оценок' / '14 912' → 5819 / 14912. Если не нашли — 0."""
if not raw:
return 0
digits = re.sub(r"\D", "", raw)
return int(digits) if digits else 0
def is_garbage_social(url: str | None) -> bool:
"""True если ссылка на соцсеть — это футер Яндекса (vk.com/yandex.maps и подобное)."""
if not url:
return True
s = url.lower()
garbage_markers = ("yandex", "yandex.maps", "yandexmaps")
return any(m in s for m in garbage_markers)
def extract_phones_from_text(text: str | None) -> list[str]:
"""Из произвольного текста (описания группы ВК и т.п.) → список E.164 телефонов."""
if not text:
return []
# Префикс +7 / 7 / 8, затем код (3 цифры) и номер. Разделители — пробелы,
# дефисы, скобки (их может быть несколько подряд: "+7 (495) ...").
# ПРИМ.: раньше тут был класс [\+7|8] (один символ из {+,7,|,8}) — он не ловил
# "+7 (495)..." и считал '|' разделителем. Заменено на корректную альтернацию.
pattern = r"(?:\+?7|8)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}"
raw_matches = re.findall(pattern, text)
normalized = [normalize_phone(m) for m in raw_matches]
return [p for p in normalized if p] # отфильтровать None
def extract_emails_from_text(text: str | None) -> list[str]:
"""Email из произвольного текста."""
if not text:
return []
pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
# Отсекаем ложные совпадения в именах файлов (retina-картинки logo@2x.png и т.п.)
bad_ext = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico")
return [e for e in re.findall(pattern, text) if not e.lower().endswith(bad_ext)]
if __name__ == "__main__":
# Простой smoke-test
assert normalize_phone("+7 (495) 258-08-88") == "+74952580888"
assert normalize_phone("8 800 700-41-17") == "+78007004117"
assert normalize_phone("ерунда") is None
assert normalize_domain("https://www.gvidon.wrf.su/?utm_campaign=x") == "gvidon.wrf.su"
assert parse_rating("5,0") == 5.0
assert parse_rating("4.6") == 4.6
assert parse_rating(None) is None
assert parse_reviews_count("5819 оценок") == 5819
assert parse_reviews_count("14 912 оценок") == 14912
assert is_garbage_social("https://vk.com/yandex.maps") is True
assert is_garbage_social("https://vk.com/karavaeviru") is False
# normalize_phone отсекает мусорные/невалидные номера (placeholder, левые коды)
assert normalize_phone("+73333333333") is None, "placeholder не должен проходить"
assert normalize_phone("8 (393) 132-86-45") is None, "несуществующий код 393"
# extract_phones_from_text — теперь ловит и "+7 (495)" формат, и "8 800"
phones = extract_phones_from_text("Звоните: +7 (495) 258-08-88 или 8 800 700-41-17")
assert phones == ["+74952580888", "+78007004117"], phones
# extract_emails_from_text — не должен возвращать имена картинок (logo@2x.png)
emails = extract_emails_from_text("логотип logo@2x.png, почта Info@Cafe.RU")
assert emails == ["Info@Cafe.RU"], emails
# is_valid_inn — контрольная сумма отсекает фейки с footer'ов сайтов
assert is_valid_inn("7707083893") is True # Сбербанк (10-знач)
assert is_valid_inn("7703427670") is True # реальный из прогона (10-знач)
assert is_valid_inn("888800000099") is False # заглушка с footer (12-знач)
assert is_valid_inn("1234567890") is False # случайные 10 цифр
assert is_valid_inn("770708389") is False # 9 цифр — не ИНН
# is_valid_ogrn — контрольная цифра ОГРН/ОГРНИП
assert is_valid_ogrn("1027700132195") is True # Сбербанк (13-знач)
assert is_valid_ogrn("1234567890123") is False # фейк (13-знач)
assert is_valid_ogrn("12345") is False # не та длина
print("✅ normalization.py — все smoke-тесты пройдены")