init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""Нормализация телефонов, доменов, чисел.
|
||||
|
||||
Принцип: парсер возвращает грязные данные → нормализация делает их каноничными
|
||||
для дедупликации.
|
||||
"""
|
||||
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-тесты пройдены")
|
||||
Reference in New Issue
Block a user