f78f35fb3f
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
700 lines
34 KiB
Python
700 lines
34 KiB
Python
"""ЕГРЮЛ-обогащение лидов.
|
||
|
||
Источник: 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'<meta[^>]+property="og:title"[^>]+content="[^"]*ИНН\s*(\d{10,12})',
|
||
r'<title>[^<]*ИНН\s*(\d{10,12})',
|
||
# В meta description
|
||
r'<meta[^>]+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'<meta[^>]+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:]*[^<]*?<a[^>]+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'<a[^>]+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"<title>\s*ИП\s+[А-ЯЁ]", html))
|
||
|
||
inn = _find_first(PATTERNS["inn"], html)
|
||
ogrn = _find_first(PATTERNS["ogrn"], html)
|
||
|
||
director = None
|
||
if is_ip_page:
|
||
# Для ИП — извлекаем ФИО предпринимателя из title:
|
||
# "<title>ИП Симонян Асмик Вардановна, село Угловая (ИНН ...)</title>"
|
||
m = re.search(
|
||
r"<title>\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"<h1[^>]*>\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 может быть несколько
|
||
<h1>, причём первый — рекламный блок другой компании. Реальное имя
|
||
компании всегда в <title>. Поэтому приоритет:
|
||
1. <title> — главный источник (Rusprofile генерирует его из карточки)
|
||
2. <h1> — fallback если title пустой
|
||
|
||
Пример title: «АО "Вкусвилл" Черноголовка (ИНН 7734443270) адрес и реквизиты»
|
||
→ возвращаем 'АО "Вкусвилл" Черноголовка'
|
||
"""
|
||
# 1. <title> — приоритет (главный)
|
||
m = re.search(r"<title>([^<]+)</title>", 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. <h1> — fallback
|
||
m = re.search(r"<h1[^>]*>([^<]{3,300})</h1>", 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}")
|
||
|