"""Нормализация телефонов, доменов, чисел. Принцип: парсер возвращает грязные данные → нормализация делает их каноничными для дедупликации. """ 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-тесты пройдены")