"""ЕГРЮЛ-обогащение лидов.
Источник: Rusprofile.ru — публичная база, без авторизации, бесплатно.
Парсим страницу поиска → переходим на детальную → достаём ИНН, ОГРН,
директора, дату регистрации, юр.адрес.
Стратегия запросов:
- Случайный User-Agent
- timeout 10 сек
- При 403/429 — пропускаем (логируем как 'error')
- Пауза между лидами в run_egrul_enrichment, не здесь
Ограничения:
- Поиск идёт по названию → могут быть промахи (в Rusprofile компания
может быть зарегистрирована под другим юр.названием).
- Для повышения точности можно фильтровать по городу через city
параметр (опционально).
"""
import logging
import re
from datetime import datetime
from typing import Optional
from urllib.parse import quote
import requests
import urllib3
from fake_useragent import UserAgent
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
_ua = UserAgent()
BASE_URL = "https://www.rusprofile.ru"
SEARCH_URL = BASE_URL + "/search?query={query}&type=ul"
# Регекспы для парсинга детальной страницы Rusprofile
PATTERNS = {
# ИНН — 10 цифр (юр. лицо) или 12 цифр (ИП).
# Стратегия: ищем рядом со словом "ИНН" в окне до 200 символов.
"inn": [
# Прямое совпадение с itemprop / meta
r'itemprop="taxID?"[^>]*>(\d{10,12})',
r'itemprop="taxID?"[^>]*content="(\d{10,12})"',
# В meta og:title или title часто есть полное название с ИНН
r']+property="og:title"[^>]+content="[^"]*ИНН\s*(\d{10,12})',
r'
[^<]*ИНН\s*(\d{10,12})',
# В meta description
r']+name="description"[^>]+content="[^"]*ИНН\s*(\d{10,12})',
# Generic после слова ИНН в окне 200 символов (но не КПП!)
r'\bИНН\b(?![^>]*КПП)[\s\S]{1,200}?>(\d{10,12})<',
r'\bИНН\b[\s\S]{1,80}?(\d{10,12})',
],
"ogrn": [
r'itemprop="vatID"[^>]*>(\d{13,15})',
r']+content="[^"]*ОГРН\s*(\d{13,15})',
r'\bОГРН\b[\s\S]{1,200}?>(\d{13,15})<',
r'\bОГРН\b[\s\S]{1,80}?(\d{13,15})',
],
"director_name": [
# Структурированные данные (legacy — Rusprofile убрал к 2026)
r'itemprop="ceoName"[^>]*>([^<]{5,100})',
r'itemprop="employee"[\s\S]{1,200}?itemprop="name"[^>]*>([^<]{5,100})',
# 2026: Rusprofile показывает директора в AI-генерируемом описании:
# "Генеральным директором ... является ... — Наталия Юрьевна Нестерова"
# либо "Руководителем ... является ... Иванов Иван Иванович"
r'(?:Генеральным\s+директором|Руководителем)[\s\S]{1,200}?[—–-]\s*([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)\b',
r'(?:Генеральный\s+директор|Руководитель)[\s:]*[—–-]?\s*([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)\b',
# Текстовый поиск через >...< (legacy с structured разметкой)
r'(?:Генеральный директор|Директор|Руководитель)[\s\S]{1,400}?>([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)<',
],
"registration_date": [
# ISO datetime атрибут (предпочтительно)
r'itemprop="foundingDate"[^>]*content="(\d{4}-\d{2}-\d{2})',
r'itemprop="foundingDate"[^>]*>(\d{4}-\d{2}-\d{2})',
r'datetime="(\d{4}-\d{2}-\d{2})"[^>]*itemprop="foundingDate"',
# Текстовый формат "12 марта 2018"
r'(?:Дата регистрации|Зарегистрирован[аои]?)[\s\S]{1,200}?(\d{1,2}\s+(?:января|февраля|марта|апреля|мая|июня|июля|августа|сентября|октября|ноября|декабря)\s+\d{4})',
# Формат "12.03.2018"
r'(?:Дата регистрации|Зарегистрирован[аои]?)[\s\S]{1,200}?(\d{1,2}\.\d{1,2}\.\d{4})',
],
"address_legal": [
# Структурированный адрес
r'itemprop="address"[\s\S]{1,500}?itemprop="streetAddress"[^>]*>([^<]{10,300})',
r'itemprop="address"[^>]*>([^<]{10,300})',
# Текстовый — после "Юридический адрес"
r'(?:Юридический адрес|Адрес юр\.?|Адрес\s+регистрации)[\s\S]{1,300}?>([А-ЯЁ][^<]{15,300})<',
],
"website": [
# Структурированный URL компании
r'itemprop="url"[^>]+href="(https?://[^"]{4,200})"',
r'itemprop="url"[^>]*>(https?://[^<]{4,200})',
# Текстовый — "Сайт компании: URL"
r'(?:Сайт\s+компании|Веб-сайт|Сайт)[\s:]*[^<]*?]+href="(https?://[^"]{4,200})"',
# data-атрибут
r'data-website="(https?://[^"]{4,200})"',
],
"phone": [
# Структурированный телефон
r'itemprop="telephone"[^>]*>([^<]{5,30})',
r'itemprop="telephone"[^>]+content="([^"]{5,30})"',
# tel: ссылки
r'href="tel:(\+?\d[\d\-\s\(\)]{7,20})"',
],
}
# Месяцы для парсинга русских дат
RU_MONTHS = {
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
}
def _headers() -> dict:
return {
"User-Agent": _ua.random,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9",
}
def _find_first(patterns: list[str], html: str) -> Optional[str]:
"""Перебор regex-паттернов, возврат первого совпадения."""
for p in patterns:
m = re.search(p, html, re.IGNORECASE | re.DOTALL)
if m:
return m.group(1).strip()
return None
def _parse_ru_date(raw: str) -> Optional[str]:
"""'12 марта 2018' / '12.03.2018' → '2018-03-12' (ISO)."""
if not raw:
return None
raw = raw.strip()
# Формат "12.03.2018"
m = re.match(r"(\d{1,2})\.(\d{1,2})\.(\d{4})", raw)
if m:
d, mo, y = m.groups()
return f"{y}-{int(mo):02d}-{int(d):02d}"
# Формат "12 марта 2018"
m = re.match(r"(\d{1,2})\s+(\w+)\s+(\d{4})", raw)
if m:
d, mon_ru, y = m.groups()
mo = RU_MONTHS.get(mon_ru.lower())
if mo:
return f"{y}-{mo:02d}-{int(d):02d}"
return None
def _strip_html(text: str) -> str:
"""Удалить HTML-теги и лишние пробелы из строки."""
if not text:
return text
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
# Должности и не-имена — отсеиваем чтобы не попадали в director_name
NON_NAME_TOKENS = {
# Должности
"генеральный", "директор", "руководитель", "управляющий",
"председатель", "совет", "совета", "учредитель",
"конкурсный", "временный", "ликвидатор", "управление",
"производство", "штукатурных", "работ", "услуги",
# Юр.формы
"общество", "ограниченной", "ответственностью",
"акционерное", "акционерного", "публичное", "непубличное",
"товарищество", "кооператив", "товарищества",
# Слова которые ЯВНО входят в названия компаний, но не в ФИО
"инвест", "групп", "групп.", "групп,", "холдинг",
"трейд", "трэйд", "системс", "технологии", "сервис",
"хаус", "плюс", "плаза", "лтд", "медиа", "капитал",
"финанс", "финансы", "консалт", "консалтинг",
"проджект", "проджектс", "девелопмент", "пром",
# Англо-буквы тоже встречаются в названиях
"ай", "ти", "би", "ви", "энд",
}
def _validate_director_name(
raw: str | None,
company_name: str | None = None,
) -> str | None:
"""Проверить что это похоже на ФИО (Фамилия Имя [Отчество]).
Отсекает:
• Должности и обрывки ('Генеральный директор', 'Конкурсный управляющий')
• Слова входящие в название компании (защита от «Ти Инвест» как
director_name когда компания — «ООО Инвест Ай Ти»). Если хотя бы
2 слова из кандидата встречаются в company_name — это название.
"""
if not raw:
return None
cleaned = _strip_html(raw)
if not cleaned:
return None
words = cleaned.split()
if not (2 <= len(words) <= 4):
return None
# Все слова должны начинаться с прописной русской буквы и быть длиннее 2 символов
for w in words:
if len(w) < 2:
return None
if not re.match(r"^[А-ЯЁ][а-яё-]+$", w):
return None
if w.lower() in NON_NAME_TOKENS:
return None
# Защита: если слова из «директора» входят в название компании →
# это не ФИО, а кусок названия (кейс: ООО «Инвест Ай Ти» → «Ти Инвест»).
if company_name:
# Нормализуем company_name — берём только русские слова длиннее 3 символов
company_words = {
w.lower() for w in re.findall(r"[А-ЯЁа-яё]{4,}", company_name)
if w.lower() not in NON_NAME_TOKENS
}
if company_words:
# Считаем сколько слов кандидата встречаются в названии
overlap = sum(1 for w in words if w.lower() in company_words)
# Если ≥2 слов входят в название → это кусок названия, не ФИО
if overlap >= 2:
return None
# Если кандидат 2 слова и 1 из них в названии → подозрительно, отказ
if len(words) == 2 and overlap >= 1:
return None
return cleaned
def _find_first_company_url(search_html: str) -> Optional[str]:
"""Найти URL первой компании/ИП в результатах поиска Rusprofile.
/id/N — юр.лица (ООО, АО, ...)
/ip/N — индивидуальные предприниматели
"""
m = re.search(r']+href="(/(?:id|ip)/\d+)"[^>]*>', search_html)
if m:
return BASE_URL + m.group(1)
return None
def _empty_result() -> dict:
"""Базовая структура результата с пустыми полями."""
return {
"inn": None,
"ogrn": None,
"director_name": None,
"registration_date": None,
"address": None,
"website": None,
"phone_primary": None,
"egrul_checked_at": datetime.now().isoformat(timespec="seconds"),
"egrul_status": "error",
}
def _parse_company_detail(
html: str,
result: dict,
company_name_query: str | None = None,
) -> dict:
"""Распарсить детальную страницу Rusprofile (/id/N или /ip/N) —
извлечь ИНН/ОГРН/директора (или ФИО ИП)/дату/адрес.
company_name_query — оригинальное имя по которому искали (для anti-false-positive
в validate_director_name: если ФИО содержит слова из названия компании, это
кусок названия а не ФИО).
"""
# Ограничиваем HTML для regex (защита от backtracking на больших страницах)
if len(html) > 500_000:
html = html[:500_000]
# Распознаём — это страница ИП или юр.лица
is_ip_page = bool(re.search(r"\s*ИП\s+[А-ЯЁ]", html))
inn = _find_first(PATTERNS["inn"], html)
ogrn = _find_first(PATTERNS["ogrn"], html)
director = None
if is_ip_page:
# Для ИП — извлекаем ФИО предпринимателя из title:
# "ИП Симонян Асмик Вардановна, село Угловая (ИНН ...)"
m = re.search(
r"\s*ИП\s+([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)",
html,
)
if m:
director = _validate_director_name(m.group(1), company_name_query) or None
# Если в title не нашли — пробуем h1
if not director:
m = re.search(
r"]*>\s*ИП\s+([А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?)",
html,
)
if m:
director = _validate_director_name(m.group(1), company_name_query) or None
else:
# Для ООО — стандартный перебор паттернов «Генеральным директором ... — ФИО»
for p in PATTERNS["director_name"]:
for match in re.finditer(p, html, re.IGNORECASE | re.DOTALL):
candidate = match.group(1).strip()
validated = _validate_director_name(candidate, company_name_query)
if validated:
director = validated
break
if director:
break
reg_date_raw = _find_first(PATTERNS["registration_date"], html)
address_raw = _find_first(PATTERNS["address_legal"], html)
website_raw = _find_first(PATTERNS["website"], html)
phone_raw = _find_first(PATTERNS["phone"], html)
if inn:
result["inn"] = inn
if ogrn:
result["ogrn"] = ogrn
if director:
result["director_name"] = director
if reg_date_raw:
result["registration_date"] = _parse_ru_date(reg_date_raw)
if address_raw:
result["address"] = _strip_html(address_raw)
if website_raw:
ws = website_raw.strip()
# Фильтр шумных доменов (rusprofile сам себя, схемы аналитики)
if not any(b in ws.lower() for b in ("rusprofile.ru", "yandex.ru/maps", "search?")):
result["website"] = ws
if phone_raw:
# Нормализуем телефон до цифр + ведущей "+"
digits = re.sub(r"[^\d+]", "", phone_raw)
if 10 <= len(re.sub(r"\D", "", digits)) <= 15:
result["phone_primary"] = digits
if any(result[k] for k in ("inn", "ogrn", "director_name", "registration_date")):
result["egrul_status"] = "found"
else:
result["egrul_status"] = "not_found"
return result
def enrich_egrul_by_inn(
inn: str,
timeout: float = 10.0,
company_name: str | None = None,
) -> dict:
"""Поиск в Rusprofile по уже известному ИНН (для WB/HH-лидов с ИНН).
Стратегия: /search?query={INN} → если Rusprofile уверен в совпадении,
делает редирект на /id/N (ООО) или /ip/N (ИП).
Если НЕ редиректит (остаётся на /search) — это значит ИНН неоднозначен,
и доверять первой попавшейся ссылке нельзя (может быть чужая компания
с похожими цифрами). В этом случае возвращаем not_found.
Дополнительно: ВЕРИФИЦИРУЕМ что на детальной странице действительно
наш ИНН (защита от ложных редиректов).
Возвращает тот же dict что enrich_egrul.
"""
result = _empty_result()
if not inn:
result["egrul_status"] = "not_found"
return result
try:
# ИНН в search → ожидаем редирект на /id/N или /ip/N
search_url = SEARCH_URL.format(query=quote(inn))
resp = requests.get(
search_url, headers=_headers(), timeout=timeout, verify=False,
allow_redirects=True,
)
if resp.status_code in (403, 429):
logger.warning(f" Rusprofile blocked us ({resp.status_code}) для ИНН {inn}")
return result
if resp.status_code != 200:
return result
# Если остались на /search → ищем РОВНО ОДНУ ссылку /id/ или /ip/.
# Если ноль или >1 ссылок — ИНН неоднозначен, отказываемся (safety).
# Был кейс: ИП Гильмизянов 166023395678 → Rusprofile показал страницу
# results со ссылками на чужие компании → подсунули чужого директора.
target_url: str | None = None
if "/id/" not in resp.url and "/ip/" not in resp.url:
ip_links = re.findall(r'href="(/ip/\d+)"', resp.text)
id_links = re.findall(r'href="(/id/\d+)"', resp.text)
ip_uniq = list(dict.fromkeys(ip_links))
id_uniq = list(dict.fromkeys(id_links))
# Однозначное совпадение: ровно 1 ссылка одного типа, ни одной другого
if len(ip_uniq) == 1 and len(id_uniq) == 0:
target_url = BASE_URL + ip_uniq[0]
elif len(id_uniq) == 1 and len(ip_uniq) == 0:
target_url = BASE_URL + id_uniq[0]
else:
result["egrul_status"] = "not_found"
return result
# Открываем единственную найденную ссылку
resp = requests.get(target_url, headers=_headers(), timeout=timeout, verify=False)
if resp.status_code != 200:
return result
# Прямой редирект ИЛИ переход по единственной ссылке — парсим
# и обязательно верифицируем ИНН (защита от чужого редиректа,
# как случай Хошафовой 616821187962 → /ip/319619600193563 (Андреев)).
if inn not in resp.text:
logger.debug(
f" ИНН {inn} не найден в HTML страницы — ложный редирект, skip"
)
result["egrul_status"] = "not_found"
return result
return _parse_company_detail(resp.text, result, company_name_query=company_name)
except requests.exceptions.Timeout:
logger.debug(f" Timeout на Rusprofile для ИНН {inn}")
except requests.exceptions.RequestException as e:
logger.debug(f" Ошибка Rusprofile для ИНН {inn}: {e}")
except Exception as e:
logger.exception(f" Неожиданная ошибка для ИНН {inn}: {e}")
return result
def _normalize_for_match(s: str) -> str:
"""Нормализовать строку для fuzzy-сравнения названий компаний.
Удаляем юр.формы, кавычки, знаки препинания, lowercase.
«ООО "Ромашка"» → «ромашка»
«Общество с ограниченной ответственностью «Альфа Бета»» → «альфа бета»
"""
if not s:
return ""
s = s.lower()
# Убираем юр.формы и шумные слова
s = re.sub(
r"\b(?:общество\s+с\s+ограниченной\s+ответственностью|"
r"индивидуальный\s+предприниматель|"
r"акционерное\s+общество|"
r"публичное\s+акционерное\s+общество|"
r"непубличное\s+акционерное\s+общество|"
r"товарищество\s+на\s+вере|"
r"ооо|ип|ао|пао|зао|нко|оао|гк|кб|нпф|пкф|тд)\b",
"",
s,
)
# Убираем кавычки/пунктуацию
s = re.sub(r"[«»\"'`'.,()]", " ", s)
s = re.sub(r"\s+", " ", s).strip()
return s
def _strip_hh_suffixes(name: str) -> str:
"""Срезать HR-локационные суффиксы из HH-имён.
HH добавляет к названию работодателя типичные суффиксы вроде
«. Центральный офис», «, Работа в магазине», «Бизнес и инфраструктура».
Они НЕ часть юр.названия и портят fuzzy-match с Rusprofile.
Примеры:
'ПАО Совкомбанк. Центральный офис.' → 'ПАО Совкомбанк'
'АШАН Ритейл Россия, Работа в магазине' → 'АШАН Ритейл Россия'
'ПАО Банк ПСБ, Бизнес и инфраструктура' → 'ПАО Банк ПСБ'
'ОАО Концерн Радиоэлектронные технологии, ОАО, УК' → 'ОАО Концерн Радиоэлектронные технологии'
'Перекресток. Кафе Select' → 'Перекресток'
"""
if not name:
return name
# Известные HR-суффиксы (после первой запятой/точки/слэша). Сравниваем case-insensitive.
HR_MARKERS = [
"центральный офис", "центральный офис", "головной офис",
"работа в магазине", "работа в офисе", "работа в ресторане",
"бизнес и инфраструктура", "офис продаж",
"кафе select",
"представительство", "филиал",
"ук", "управляющая компания",
]
parts = re.split(r"[.,/]", name, maxsplit=1)
if len(parts) == 2:
head, tail = parts[0].strip(), parts[1].strip().lower()
# Если хвост содержит маркер локации — отбрасываем хвост
for m in HR_MARKERS:
if m in tail:
return head
# Если хвост короткий и не похож на ключевую часть — тоже отбрасываем
# (например "Перекресток. Кафе Select" — хвост "Кафе Select" не специфичен)
if len(tail) <= 30 and any(w in tail for w in ("офис", "точка", "магазин", "ресторан")):
return head
return name
def _name_match_score(found: str, query: str) -> float:
"""0.0-1.0: насколько найденное название похоже на искомое.
Считаем долю слов из query (длиной ≥3) которые встречаются в found.
Если в query одно слово — ищем по подстроке.
"""
f = _normalize_for_match(found)
q = _normalize_for_match(query)
if not f or not q:
return 0.0
q_words = [w for w in q.split() if len(w) >= 3]
if not q_words:
return 0.0
if len(q_words) == 1:
return 1.0 if q_words[0] in f else 0.0
matched = sum(1 for w in q_words if w in f)
return matched / len(q_words)
def _extract_company_title(html: str) -> str:
"""Извлечь название компании со страницы Rusprofile.
КРИТИЧНО (исправлено 2026-05-21): на странице /id/N может быть несколько
, причём первый — рекламный блок другой компании. Реальное имя
компании всегда в . Поэтому приоритет:
1. — главный источник (Rusprofile генерирует его из карточки)
2. — fallback если title пустой
Пример title: «АО "Вкусвилл" Черноголовка (ИНН 7734443270) адрес и реквизиты»
→ возвращаем 'АО "Вкусвилл" Черноголовка'
"""
# 1. — приоритет (главный)
m = re.search(r"([^<]+)", html, re.IGNORECASE)
if m:
title = m.group(1)
# Срезаем хвосты: "(ИНН ...) адрес", ", г.Москва", "адрес и реквизиты"
title = re.split(r"[(,]|(?:\sадрес\s|\sИНН\s)", title, maxsplit=1)[0]
title = title.strip()
if title and len(title) >= 3:
return title
# 2. — fallback
m = re.search(r"]*>([^<]{3,300})
", html, re.IGNORECASE)
if m:
return _strip_html(m.group(1))
return ""
def _looks_too_generic(name: str) -> bool:
"""True если имя слишком общее/короткое — Rusprofile подсунет случайную компанию."""
cleaned = _normalize_for_match(name)
if not cleaned:
return True
words = cleaned.split()
# 1 слово короче 6 символов → точно общее
if len(words) == 1 and len(words[0]) < 6:
return True
# 2+ коротких слов в имени из 3 букв — общее
if len(words) >= 2 and all(len(w) <= 4 for w in words):
return True
return False
def enrich_egrul(
name: str,
city: str | None = None,
timeout: float = 10.0,
debug_dump_html: str | None = None,
) -> dict:
"""Поиск компании в Rusprofile по названию С УСИЛЕННОЙ ВАЛИДАЦИЕЙ.
Стратегия:
1. /search?query={name} → ждём редирект на /id/N или /ip/N
2. Если редирект — fuzzy-сравнение найденного title с искомым name.
Если совпадение слабое (<50%) — отказ, чтобы не подсунуть чужого.
3. Если /search НЕ редиректит — ищем строго одну ссылку /id/ или /ip/
(без множественных кандидатов) И только если имя достаточно
специфичное (не «Банкирро» / «Флант»).
Цель: лучше not_found чем выдать чужого директора.
"""
result = _empty_result()
if not name:
result["egrul_status"] = "not_found"
return result
# Срезаем HR-суффиксы из HH-имён («ПАО Совкомбанк. Центральный офис.» →
# «ПАО Совкомбанк»). Иначе fuzzy-match не пройдёт — Rusprofile не знает
# «Центральный офис» как часть юр.названия.
name_for_search = _strip_hh_suffixes(name)
if name_for_search != name:
logger.debug(f" HR-suffix stripped: {name!r} → {name_for_search!r}")
# Слишком общие имена («Флант», «MOOD», «4hands») — Rusprofile в 99%
# подсунет случайную совпавшую компанию. Лучше сразу отказ.
if _looks_too_generic(name_for_search):
logger.debug(f" enrich_egrul: '{name_for_search}' слишком общее → skip")
result["egrul_status"] = "not_found"
return result
# Уточняем поиск городом если есть
query = name_for_search
if city and city.lower() != "москва":
query = f"{name_for_search} {city}"
try:
# 1. Поиск
search_url = SEARCH_URL.format(query=quote(query))
resp = requests.get(
search_url, headers=_headers(), timeout=timeout, verify=False,
allow_redirects=True,
)
if resp.status_code in (403, 429):
logger.warning(f" Rusprofile blocked us ({resp.status_code}) для '{name}'")
return result
if resp.status_code != 200:
logger.debug(f" Rusprofile вернул {resp.status_code} для '{name}'")
return result
# Если редирект на /id/N или /ip/N — Rusprofile уверен в матче.
# Если на /search — кандидатов много, доверять опасно.
if "/id/" in resp.url or "/ip/" in resp.url:
html = resp.text
# Fuzzy-проверка названия: то ли это что мы искали?
found_title = _extract_company_title(html)
score = _name_match_score(found_title, name)
if score < 0.3:
logger.debug(
f" enrich_egrul: '{name}' → найдено '{found_title[:60]}' "
f"(name_match={score:.2f}) — не совпадает, skip"
)
result["egrul_status"] = "not_found"
return result
else:
# /search не редиректит → берём ПЕРВУЮ ссылку (Rusprofile сортирует
# по релевантности). Защита от подсунутой чужой компании —
# fuzzy-сравнение title на следующем шаге.
company_url = _find_first_company_url(resp.text)
if not company_url:
logger.debug(f" enrich_egrul: '{name}' — ни одной /id/ или /ip/ ссылки")
result["egrul_status"] = "not_found"
return result
resp = requests.get(company_url, headers=_headers(), timeout=timeout, verify=False)
if resp.status_code != 200:
return result
html = resp.text
# Главная защита: title найденной компании должен быть похож на искомое имя
found_title = _extract_company_title(html)
score = _name_match_score(found_title, name)
if score < 0.3:
logger.debug(
f" enrich_egrul: '{name}' → '{found_title[:60]}' "
f"(name_match={score:.2f}) — не совпадает, skip"
)
result["egrul_status"] = "not_found"
return result
# Debug: сохранить HTML для отладки regex'ов
if debug_dump_html:
try:
with open(debug_dump_html, "w", encoding="utf-8") as f:
f.write(html)
logger.info(f" [debug] HTML сохранён в {debug_dump_html}")
except Exception as e:
logger.warning(f" [debug] Не удалось сохранить HTML: {e}")
# Извлекаем все поля (один общий парсер для by_name и by_inn).
# Передаём company_name=name_for_search (без HR-суффиксов) чтобы
# валидатор директора отсекал слова из названия.
return _parse_company_detail(html, result, company_name_query=name_for_search)
except requests.exceptions.Timeout:
logger.debug(f" Timeout на Rusprofile для '{name}'")
except requests.exceptions.RequestException as e:
logger.debug(f" Ошибка Rusprofile для '{name}': {e}")
except Exception as e:
logger.exception(f" Неожиданная ошибка ЕГРЮЛ для '{name}': {e}")
return result
if __name__ == "__main__":
# Smoke-тест на реальных названиях из БД.
# У первой компании сохраняем HTML для отладки regex'ов → debug_rusprofile.html
logging.basicConfig(level=logging.INFO, format="%(message)s")
test_names = [
("Гвидон", "Москва"),
("Кафе Пушкинъ", "Москва"),
("Тануки", "Москва"),
]
for idx, (name, city) in enumerate(test_names):
print(f"\n→ Поиск: {name} ({city})")
# Для первой компании сохраняем HTML
debug = "debug_rusprofile.html" if idx == 0 else None
info = enrich_egrul(name, city, debug_dump_html=debug)
for k, v in info.items():
print(f" {k}: {v}")