"""Tier 2 enrichment — анализ сайта компании.
Делает 1 HTTP-запрос → ищет в HTML маркеры:
- CMS / конструктор сайта (tilda, wix, wordpress, bitrix, ...)
- Live-чат (jivo, talk-me, ...)
- Онлайн-запись / онлайн-бронирование (yclients, dikidi, ...)
- Аналитика (Я.Метрика, Google Analytics, GTM)
- Email на странице → корпоративный или бесплатный домен
Все детекторы — по text-маркерам в HTML.
False positives возможны (например, упоминание "yclients" в блоге),
но для скоринга сигнал достаточный.
"""
import logging
import re
from datetime import datetime
from typing import Optional
import requests
import urllib3
from normalization import extract_phones_from_text, normalize_domain, is_valid_inn, is_valid_ogrn
# Глушим спам InsecureRequestWarning — verify=False нужен из-за множества старых сайтов
# с просроченными SSL-сертификатами. Это безопасно, т.к. мы только читаем HTML, не передаём данные.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
# ───────────────────────────────────────────────────────────────────────
# Сигнатуры — что искать в HTML (всё в нижнем регистре)
# ───────────────────────────────────────────────────────────────────────
CMS_SIGNATURES: dict[str, list[str]] = {
"tilda": ["tilda.cc", "tildacdn", "tilda-blocks", "t-rec"],
"wix": ["wixstatic.com", "wix.com/script", "_wixcssmodules"],
"wordpress": ["wp-content", "wp-includes", "wp-json"],
"bitrix": ["bitrix/", "bx-core", "/bitrix/js"],
"modx": ["modx.com", "/manager/modx", "modxcms"],
"joomla": ["joomla!", "/components/com_", "/media/jui/"],
"drupal": ["drupal.settings", "/sites/all/", "/sites/default/"],
"webflow": ["webflow.io", "webflow.com", "wf-tabs"],
"squarespace":["squarespace.com", "static1.squarespace"],
"insales": ["insales.ru", "insales-cdn"],
"shopify": ["cdn.shopify.com", "shopify.com/s/files"],
"opencart": ["catalog/view/theme", "route=common", "route=product"],
"1c-bitrix-sites": ["sites.bitrix24", "bitrix24.site"],
"readymag": ["readymag.com"],
"craftum": ["craftum.com", "craftumusercontent"],
}
# Конструкторы / авто-визитки — определяются по ДОМЕНУ (надёжнее HTML-сигнатур).
# Я.Бизнес (clients.site / business.site) — авто-сайт из Яндекс.Бизнеса,
# слабейшее веб-присутствие; его движок НЕ детектится как CMS по HTML → ловим по URL.
BUILDER_DOMAINS = {
"clients.site": "yandex_business",
"business.site": "yandex_business",
"tilda.ws": "tilda",
"wixsite.com": "wix",
"nethouse.": "nethouse",
"taplink.": "taplink",
".ucoz.": "ucoz",
}
LIVE_CHAT_SIGNATURES = [
"jivosite", "jivo.ru", "jivochat",
"talk-me", "talkme.ru",
"carrotquest", "carrot-quest",
"usedesk",
"verbox.ru",
"redhelper",
"chat2desk",
"webim.ru",
"tawk.to", "embed.tawk",
"crisp.chat",
"intercom.io", "widget.intercom",
"livechatinc.com",
"callbackhunter",
"callback24",
]
ONLINE_BOOKING_SIGNATURES = [
"yclients.com", "n.yclients",
"dikidi.ru", "dikidi.net",
"ucalendar",
"altegio",
"sonline.su",
"gbooking",
"tickt.ee",
"reservepad",
"bookform",
"rezgo",
# Текстовые маркеры (русский интерфейс)
"онлайн-запис", "онлайн запис",
"забронировать столик", "забронировать стол",
]
ANALYTICS_SIGNATURES = [
# Яндекс.Метрика
"mc.yandex.ru/metrika", "yandex_metrika", "ym(",
# Google Analytics + GTM
"google-analytics.com", "googletagmanager.com",
"gtag(", "ga('send'", "ga('create'",
# Mail.ru top
"top.mail.ru", "top-fwz1.mail.ru",
]
# Домены бесплатной почты — если email на них, у компании нет своего email-сервера.
FREE_EMAIL_DOMAINS = {
"gmail.com", "googlemail.com",
"mail.ru", "list.ru", "bk.ru", "inbox.ru",
"yandex.ru", "ya.ru", "yandex.com",
"rambler.ru", "lenta.ru", "myrambler.ru",
"hotmail.com", "outlook.com", "live.com",
"yahoo.com",
"icloud.com", "me.com",
"protonmail.com",
"qq.com", "163.com",
}
# Ограничения длины по RFC 5321: local-part ≤ 64, domain ≤ 253, TLD ≤ 24.
# Это защищает от catastrophic backtracking на патологически длинных входах.
EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,24}")
# ───────────────────────────────────────────────────────────────────────
# Детекторы (atomic — каждая делает одну вещь)
# ───────────────────────────────────────────────────────────────────────
def detect_cms(html_lower: str) -> Optional[str]:
"""Возвращает ключ из CMS_SIGNATURES либо 'custom' если ничего не нашли."""
for cms_name, signatures in CMS_SIGNATURES.items():
if any(sig in html_lower for sig in signatures):
return cms_name
return "custom"
def detect_cms_by_url(url: Optional[str]) -> Optional[str]:
"""Определить конструктор по домену (Я.Бизнес и пр.). None если не конструктор."""
u = (url or "").lower()
for frag, cms in BUILDER_DOMAINS.items():
if frag in u:
return cms
return None
def detect_any(html_lower: str, signatures: list[str]) -> bool:
return any(sig in html_lower for sig in signatures)
def extract_emails(html: str) -> list[str]:
"""Все email со страницы (с сохранением порядка, без дублей)."""
raw = EMAIL_PATTERN.findall(html)
seen: set[str] = set()
result: list[str] = []
for email in raw:
e = email.lower()
if e not in seen:
seen.add(e)
result.append(e)
return result
def classify_email_domain(email: str) -> str:
"""'corporate' если домен email — собственный, 'free' если из FREE_EMAIL_DOMAINS."""
if "@" not in email:
return "free"
domain = email.split("@", 1)[1].lower()
return "free" if domain in FREE_EMAIL_DOMAINS else "corporate"
# ───────────────────────────────────────────────────────────────────────
# Главная функция
# ───────────────────────────────────────────────────────────────────────
def analyze_website(url: str, timeout: float = 6.0) -> dict:
"""Запрашивает сайт, возвращает все Tier 2 поля.
Все поля под None = не удалось определить (например, сайт мёртвый).
"""
result: dict = {
"site_alive": None,
"site_status_code": None,
"cms_type": None,
"has_live_chat": None,
"has_online_booking": None,
"has_analytics": None,
"email_domain_type": None,
"site_checked_at": datetime.now().isoformat(timespec="seconds"),
# Найденные контакты со страницы сайта — для слияния в лида через update_lead_contacts.
# Эти поля НЕ входят в ENRICHMENT_FIELDS (не пишутся через update_enrichment).
"emails_found": [],
"phones_found": [],
# ИНН/ОГРН/КПП из footer сайта (152-ФЗ disclosure)
"inn": None,
"ogrn": None,
"kpp": None,
}
if not url:
return result
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0 Safari/537.36"
),
"Accept-Language": "ru-RU,ru;q=0.9",
}
try:
# timeout=(connect, read) — раздельные таймауты, чтобы сайт не мог
# повесить парсер бесконечно если соединение установилось но сервер
# отдаёт данные по чуть-чуть.
resp = requests.get(
url, headers=headers,
timeout=(timeout, timeout),
allow_redirects=True, verify=False, # многие старые сайты с битым SSL
stream=False,
)
result["site_status_code"] = resp.status_code
result["site_alive"] = 1 if 200 <= resp.status_code < 400 else 0
except requests.exceptions.SSLError:
result["site_alive"] = 0
result["site_status_code"] = -1 # маркер SSL-ошибки
return result
except requests.exceptions.Timeout:
result["site_alive"] = 0
result["site_status_code"] = -2 # маркер таймаута
return result
except requests.exceptions.RequestException as e:
logger.debug(f" Не удалось получить {url}: {e}")
result["site_alive"] = 0
result["site_status_code"] = -3 # маркер общей ошибки
return result
if not result["site_alive"]:
return result
# Анализ HTML.
# Ограничение размера: некоторые сайты отдают 5-10MB HTML
# (раздутые JS-бандлы, JSON-LD, embedded data), на которых regex
# email/phone начинает страдать catastrophic backtracking и виснет на
# минуты. Все полезные сигналы (CMS, чат, запись, аналитика, email,
# телефон) обычно в первых ~200KB страницы. Обрезаем агрессивно.
MAX_HTML_BYTES = 500_000 # 500KB
html = resp.text
if len(html) > MAX_HTML_BYTES:
logger.debug(f" HTML обрезан с {len(html)} до {MAX_HTML_BYTES} байт")
html = html[:MAX_HTML_BYTES]
html_lower = html.lower()
# Сначала по домену (Я.Бизнес и пр. авто-визитки), потом по HTML-сигнатурам.
result["cms_type"] = detect_cms_by_url(resp.url) or detect_cms(html_lower)
result["has_live_chat"] = 1 if detect_any(html_lower, LIVE_CHAT_SIGNATURES) else 0
result["has_online_booking"] = 1 if detect_any(html_lower, ONLINE_BOOKING_SIGNATURES) else 0
result["has_analytics"] = 1 if detect_any(html_lower, ANALYTICS_SIGNATURES) else 0
# ─── ИНН/ОГРН/КПП из footer сайта ────────────────────────────────────
# По 152-ФЗ юр.лица обязаны публиковать реквизиты на сайте. Часто это
# не на главной, а на /contacts/ или /o-kompanii/. Поэтому:
# 1. Ищем на главной (текущий html)
# 2. Если нет — пробуем 4 типичных contact-страницы (1-2 сек каждая)
inn_val, ogrn_val, kpp_val = _extract_inn_ogrn_kpp(html)
if not inn_val:
# Стучимся на типичные contact/about-страницы
from urllib.parse import urljoin
for path in ("/contacts/", "/contact/", "/kontakty/", "/about/",
"/o-kompanii/", "/rekvizity/", "/info/"):
sub_url = urljoin(url, path)
try:
sub_resp = requests.get(
sub_url, headers=headers,
timeout=(timeout, timeout), verify=False, stream=False,
)
if sub_resp.status_code != 200:
continue
sub_html = sub_resp.text[:MAX_HTML_BYTES]
sub_inn, sub_ogrn, sub_kpp = _extract_inn_ogrn_kpp(sub_html)
if sub_inn:
inn_val = sub_inn
ogrn_val = ogrn_val or sub_ogrn
kpp_val = kpp_val or sub_kpp
logger.debug(f" ИНН найден на {path}: {inn_val}")
break
except Exception:
continue
if inn_val:
result["inn"] = inn_val
if ogrn_val:
result["ogrn"] = ogrn_val
if kpp_val:
result["kpp"] = kpp_val
# ─── Email — приоритет MAILTO-ссылок ─────────────────────────────────
# Любой email на странице может быть шумом (партнёр, mailchimp, sentry,
# пример в документации). Реальный контакт компании — обычно в footer'е
# как . Извлекаем приоритетно ИХ.
mailto_emails = re.findall(
r'href=["\']?mailto:([a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,24})',
html,
)
if mailto_emails:
# Чистим, уникализируем, фильтруем шумные домены
clean = _filter_quality_emails(mailto_emails)
result["emails_found"] = clean[:5] # max 5 — больше уже нерелевантно
if clean:
result["email_domain_type"] = classify_email_domain(clean[0])
else:
# Fallback — текстовый поиск, но берём топ-3 по частоте и только те
# которые встречаются ≥1 раз (можно даже фильтровать ≥2 раза для шума).
all_emails = extract_emails(html)
if all_emails:
clean = _filter_quality_emails(all_emails)
# Сортируем по частоте — самые упоминаемые сверху
from collections import Counter
cnt = Counter(clean)
top = [e for e, _ in cnt.most_common(3)]
result["emails_found"] = top
if top:
result["email_domain_type"] = classify_email_domain(top[0])
# ─── Принадлежность email компании ───────────────────────────────────
# Оставляем только письма, чей домен совпадает с доменом сайта ИЛИ это
# бесплатный почтовик (компания сама опубликовала его у себя на сайте).
# Чужой корпоративный домен = разработчик темы / партнёр / отель → выкид.
result["emails_found"] = _belonging_emails(result["emails_found"], url)
result["email_domain_type"] = (
classify_email_domain(result["emails_found"][0])
if result["emails_found"] else None
)
# ─── Телефоны — приоритет TEL-ссылок ──────────────────────────────────
# Аналогично email: на странице может быть 78 совпадений regex'а с цифрами,
# это не контакты компании. Реальные контакты — в .
# D19: берём ТОЛЬКО явные ссылки. Текстовый fallback
# (скан всех чисел страницы) УБРАН — он грёб партнёрские/шаблонные/иногородние
# номера, главный источник «чужих» телефонов. Достоверный телефон — с Я.Карт;
# эти — «к проверке» (пишутся в phones_extra через update_lead_contacts).
tel_phones_raw = re.findall(r'href=["\']?tel:([+0-9\-\s\(\)]{7,30})', html)
if tel_phones_raw:
tel_phones = extract_phones_from_text("\n".join(tel_phones_raw))
result["phones_found"] = _uniq(tel_phones)[:2]
return result
def _extract_inn_ogrn_kpp(html: str) -> tuple[str | None, str | None, str | None]:
"""Извлечь ИНН (10/12 цифр), ОГРН/ОГРНИП (13/15), КПП (9) из HTML страницы.
Используется для парсинга footer'ов сайтов компаний и страниц /contacts/.
Юр.лица по 152-ФЗ обязаны публиковать ИНН на сайте.
"""
# Разделитель между меткой и числом: пробелы, двоеточие или HTML-entity
# ПРИМ.: раньше было [\s: ] — это класс из символов {пробел,:,&,n,b,s,p,;},
# а не entity. Заменено на корректную группу (?:[\s:]| )+.
sep = r"(?:[\s:]| )+"
inn_m = re.search(rf"\bИНН{sep}(\d{{10,12}})\b", html, re.IGNORECASE)
ogrn_m = re.search(rf"\bОГРН[ИП]{{0,2}}{sep}(\d{{13,15}})\b", html, re.IGNORECASE)
kpp_m = re.search(rf"\bКПП{sep}(\d{{9}})\b", html, re.IGNORECASE)
inn = inn_m.group(1) if inn_m and len(inn_m.group(1)) in (10, 12) else None
# Контрольная сумма: отсекаем фейк-ИНН с footer'ов (напр. 888800000099),
# которые проходят по длине, но не по контрольным цифрам РФ.
if inn and not is_valid_inn(inn):
inn = None
ogrn = ogrn_m.group(1) if ogrn_m and len(ogrn_m.group(1)) in (13, 15) else None
if ogrn and not is_valid_ogrn(ogrn):
ogrn = None
kpp = kpp_m.group(1) if kpp_m else None
return inn, ogrn, kpp
def _uniq(items: list[str]) -> list[str]:
"""Уникализация с сохранением порядка."""
seen: set[str] = set()
out: list[str] = []
for x in items:
if x and x not in seen:
seen.add(x)
out.append(x)
return out
# Подстроки в email которые говорят что это технический / служебный адрес
_BAD_EMAIL_SUBSTRINGS = (
"no-reply", "noreply", "mailer-daemon", "postmaster", "donotreply",
"@sentry.", "@example.", "@test.", "@localhost", "@email.com",
"@tilda.cc", "@wix.com", "@wordpress.com",
"u003e", "u003c", # JSON-escaped мусор из inline JS
".png", ".jpg", ".gif", # email в имени файла = ложное совпадение
)
def _filter_quality_emails(emails: list[str]) -> list[str]:
"""Отфильтровать технические/мусорные email + lowercase + uniq."""
out: list[str] = []
seen: set[str] = set()
for e in emails:
el = e.lower().strip()
if not el or el in seen:
continue
if any(bad in el for bad in _BAD_EMAIL_SUBSTRINGS):
continue
# Срезаем явно сломанные хвосты (типа "info@example.compng")
if re.search(r"\.(pn|jp|gi|cs|js|html?)g?$", el):
continue
seen.add(el)
out.append(el)
return out
def _belonging_emails(emails: list[str], site_url: str | None) -> list[str]:
"""Оставить только email, принадлежащие компании этого сайта.
Правило precision: домен письма == домен сайта (по 2 последним лейблам)
ИЛИ бесплатный почтовик (его компания сама опубликовала у себя на сайте).
Чужой корпоративный домен — почти всегда разработчик темы, партнёр,
агрегатор или соседний бренд → отбрасываем.
"""
def _reg(d: str | None) -> str | None:
if not d:
return None
parts = d.split(".")
return ".".join(parts[-2:]) if len(parts) >= 2 else d
site_reg = _reg(normalize_domain(site_url)) if site_url else None
out: list[str] = []
for e in emails:
dom = (e.split("@")[-1] or "").lower() if "@" in e else ""
if not dom:
continue
if dom in FREE_EMAIL_DOMAINS or (site_reg and _reg(dom) == site_reg):
out.append(e)
return out
if __name__ == "__main__":
# Smoke-тест на одном из реальных сайтов из БД
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
test_sites = [
"https://gvidon.wrf.su/", # Гвидон (из нашей БД)
"https://karavaevi.ru/", # Караваевы
"https://cafe-pushkin.ru/", # Кафе Пушкинъ
]
for site in test_sites:
print(f"\n→ {site}")
info = analyze_website(site)
for k, v in info.items():
print(f" {k}: {v}")