"""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}")