"""Поиск сайта компании по имени/ИНН — для лидов у которых нет website (например, HH-вакансии без employer-страницы, Я.Карты без сайта). ЕГРЮЛ юридически НЕ содержит сайта/телефона/email. Их нужно искать в интернете. Самый надёжный путь без капчи — DuckDuckGo HTML endpoint (https://html.duckduckgo.com/html/?q=...), он работает без авторизации и без JS, отдаёт обычный HTML с органическими результатами. После того как сайт найден — обычный website-analyzer вытащит email/phone со страницы «Контакты». Поиск идёт по двум запросам подряд: 1. "{очищенное_название} ИНН {ИНН} сайт" — если ИНН известен 2. "{очищенное_название} официальный сайт" Очищаем название от юр.формы (ООО / ИП / АО / ...) и от кавычек — вьюшка качественнее. Игнорируем агрегаторы (rusprofile, wb, ozon, hh, ya.maps, 2gis и пр.) — они не являются сайтом самой компании. """ import logging import random import re import time from urllib.parse import quote, unquote, urlparse import requests import urllib3 from fake_useragent import UserAgent urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) logger = logging.getLogger(__name__) _ua = UserAgent() # DuckDuckGo HTML endpoint — без JS / капчи, отдаёт сразу результаты SEARCH_URL = "https://html.duckduckgo.com/html/?q={query}" # Домены которые ТОЧНО не являются сайтом самой компании. # Расширенный список — после теста 2026-05-19 куда пролезли rbc.ru, saby.ru, # find-org.net, focus.kontur.ru и пр. агрегаторы. BLOCKED_DOMAINS = { # Российские агрегаторы ЕГРЮЛ / каталоги / отчётность "rusprofile.ru", "zachestnyibiznes.ru", "checko.ru", "list-org.com", "spark-interfax.ru", "kartoteka.ru", "vbankcenter.ru", "audit-it.ru", "find-org.net", "find-org.com", "k-agent.ru", "kontragent.vbr.ru", "kontur.ru", "focus.kontur.ru", "kontur-extern.ru", "saby.ru", "sbis.ru", "tensor.ru", "check.tochka.com", "tochka.com", "b2b.house", "b2bhouse.ru", "reputation.ru", "rb.ru", "rbc.ru", "companies.rbc.ru", "kartaslov.ru", "sezinnopolis.ru", "vyborg.spravker.ru", "spravker.ru", "datanewton.ru", "vsledu.ru", "1cnalog.ru", "spark.interfax.ru", "myseldon.com", "seldon.basis.ru", "casebook.ru", "ru-arbitr.ru", "kad.arbitr.ru", # Тематические агрегаторы (медицина / автосервис и т.п.) "doctu.ru", "prodoctorov.ru", "docdoc.ru", "napopravku.ru", "doc.ru", "krasotaimedicina.ru", "stomatologclub.ru", "fitauto.ru", "drom.ru", "auto.ru", "carmoney.ru", "apteka.ru", "asna.ru", "rigla.ru", "xfirm.ru", "1cinfo.ru", "1c.ru", "1cms.ru", "finjobs.ru", "nerab.ru", # Маркетплейсы "wildberries.ru", "wb.ru", "ozon.ru", "market.yandex.ru", "sbermegamarket.ru", "kazanexpress.ru", "lamoda.ru", "kupivip.ru", "aliexpress.ru", "store.steampowered.com", # явно мусор # Работа / объявления "hh.ru", "rabota.ru", "superjob.ru", "trudvsem.ru", "avito.ru", "youla.ru", # Карты / поисковики / соцсети "yandex.ru", "yandex.com", "ya.ru", "google.com", "google.ru", "duckduckgo.com", "bing.com", "mail.ru", "vk.com", "vk.ru", "vkontakte.ru", "ok.ru", "instagram.com", "facebook.com", "twitter.com", "x.com", "youtube.com", "t.me", "telegram.org", "telegram.me", "wikipedia.org", "habr.com", "pikabu.ru", "dzen.ru", "ru.wikipedia.org", "en.wikipedia.org", # 2ГИС / справочники "2gis.ru", "2gis.com", "spravochnik.org", "spravka.ru", # ФНС / гос "nalog.ru", "egrul.nalog.ru", "gosuslugi.ru", "service.nalog.ru", # Видео / прочее "rutube.ru", # Глобальные новости (не сайт компании) "globalcosmeticsnews.com", "naturestudio.com", # Китайские / международные маркетплейсы "alibaba.com", "aliexpress.com", "made-in-china.com", "1688.com", "ebay.com", "amazon.com", "amazon.de", "etsy.com", "shopify.com", "redmart.com", # Сервисы проверки деклараций / сертификации (мимо от поиска по ИНН) "dip-world.com", "декларации-соответствия.рус", "che-cko.ru", # ещё один rusprofile-клон "xn----8sbnaarbafefe1bc6dh3a4bf.xn--rus", # punycode декларации-соответствия.рус "rosakkredit.ru", "fsa.gov.ru", "rst.gov.ru", "sertifikatonline.ru", "novotest.ru", "sertifikatik.ru", "decl-tr.com", "tr-cu.com", "tr-cu.ru", # App stores / LinkedIn / прочие глобальные сервисы "apps.apple.com", "play.google.com", "linkedin.com", # Похожие на rusprofile (но другие — тоже агрегаторы) "rusprofiles.com", "rusprofiles.ru", "egrul.itsoft.ru", "itsoft.ru", "egrul-info.ru", "egrul.online", "egrul.io", # Контрагент-проверки "vbankcenter.ru", "rosfirm.info", "rosfirm.ru", "centrinform.ru", "buhonline.ru", # Радио / СМИ "echofm.online", "radio1.ru", "kommersant.ru", "rbc.ru", # Блог-платформы (не сайт компании) "blogspot.com", "wordpress.com", "wix.com", "tilda.ws", "tilda.cc", "medium.com", "livejournal.com", } def _clean_company_name(name: str) -> str: """Убрать ООО / ИП / АО / кавычки — оставить чистое имя бренда.""" if not name: return "" cleaned = re.sub( r"^(?:Общество\s+с\s+ограниченной\s+ответственностью|" r"Индивидуальный(?:\s+П|\s+п)редприниматель|" r"Акционерное\s+общество|" r"Публичное\s+акционерное\s+общество|" r"ООО|ИП|АО|ПАО|ЗАО|НКО|ОАО)\b\s*", "", name, flags=re.IGNORECASE, ) # Убираем кавычки и точки в начале/конце cleaned = cleaned.strip(" «»\"'.,").strip() return cleaned def _is_company_site(url: str) -> bool: """True если URL похож на сайт самой компании (не агрегатор и не соцсеть).""" try: host = (urlparse(url).hostname or "").lower() except Exception: return False if not host: return False # Убираем www. префикс host = host[4:] if host.startswith("www.") else host for blocked in BLOCKED_DOMAINS: if host == blocked or host.endswith("." + blocked): return False return True def _parse_ddg_results(html: str) -> list[str]: """Извлечь органические URL из DuckDuckGo HTML результата. DDG: Декодируем uddg= и возвращаем реальный URL. """ urls: list[str] = [] # Главный паттерн (с uddg-обёрткой) for m in re.finditer( r']+class="result__a"[^>]+href="([^"]+)"', html, ): href = m.group(1) m2 = re.search(r"uddg=([^&]+)", href) if m2: try: real = unquote(m2.group(1)) except Exception: continue else: real = href if real.startswith("http"): urls.append(real) # Запасной паттерн — без класса (на случай если DDG поменял разметку) if not urls: for m in re.finditer(r']+href="(https?://[^"]+)"[^>]*>', html): urls.append(m.group(1)) return urls def _verify_site_belongs_to_company( url: str, name_cleaned: str, inn: str | None, timeout: float = 6.0, ) -> bool: """Проверить что найденный сайт реально принадлежит этой компании. ЖЁСТКИЕ ПРАВИЛА (после фейлов с kamaz≠АМАЗ, fscosmetics≠Cosmetics): 1. Если у лида есть ИНН — он ОБЯЗАН быть на сайте дословно. Иначе отказ. Это сильнейший сигнал, нет ИНН на сайте = не их сайт. 2. Если ИНН нет — проверяем только полное совпадение названия бренда по WORD-BOUNDARIES (не как подстрока). 'АМАЗ' внутри 'kamaz' не считается. Иначе отказ. Возвращает True только при уверенном совпадении. """ try: r = requests.get( url, headers={"User-Agent": _ua.random}, timeout=timeout, verify=False, allow_redirects=True, ) except Exception: return False if r.status_code != 200: return False html = r.text[:200_000] # Сильнейший сигнал — ИНН в HTML страницы. Если есть — сразу True. if inn and inn in html: return True # ИНН на главной странице есть не всегда (часто только в /контакты). # Поэтому отсутствие ИНН — НЕ приговор. Дальше проверяем имя бренда. if not name_cleaned or len(name_cleaned) < 4: return False # Список слишком общих слов — без ИНН они не помогают GENERIC = { "home", "shop", "store", "market", "company", "group", "trade", "service", "services", "center", "centre", "cosmetics", "beauty", "office", "studio", "moscow", "russia", "online", "global", "international", "магазин", "товары", "сервис", "центр", "офис", "доставка", "красота", "офис-менеджер", } # Слова бренда (≥4 символа, не из generic-списка) words = [ w for w in re.split(r"[\s«»\"'\-,.&]+", name_cleaned) if len(w) >= 4 and w.lower() not in GENERIC ] if not words: # Все слова имени — слишком общие. Без ИНН — пропускаем. return False html_lower = html.lower() # Ищем по word boundaries — слово в окружении не-буквенно-цифровых символов for w in words: # \b в Python re работает на ASCII по умолчанию. Для кириллицы # эмулируем через look-around: до и после слова должен быть НЕ-буква. pattern = ( r"(?:^|[^a-zA-Zа-яА-ЯёЁ0-9])" + re.escape(w.lower()) + r"(?:$|[^a-zA-Zа-яА-ЯёЁ0-9])" ) if re.search(pattern, html_lower): return True return False def find_company_website( name: str, inn: str | None = None, timeout: float = 8.0, verify: bool = True, ) -> str | None: """Поиск сайта компании в DuckDuckGo + верификация по содержимому. Этапы: 1. DDG-запросы от специфичного к общему (с ИНН, потом без) 2. Для каждого кандидата (≤8 за все запросы) — фильтр по blocklist'у 3. Скачиваем главную страницу → проверяем что на ней упомянут ИНН ИЛИ имя бренда. Без этого опасно: для "ИП Иванов" DDG отдаст Forbes или Омбудсмен — будут чужие email/phone. Возвращает только верифицированный URL (или None). Если имя слишком короткое/общее (1-2 слова без ИНН) — возвращает None сразу, чтобы не сосать чужие данные. """ cleaned = _clean_company_name(name) if not cleaned: return None # Защита от ложных совпадений: для "ИП Иванов Иван" без ИНН — отказ. # Слишком общее ФИО + отсутствие ИНН = гарантированно подсунут чужой сайт. if not inn: words_in_name = [w for w in re.split(r"[\s«»\"'-]+", cleaned) if w] # Эвристика: если все слова это похожи на ФИО (3 слова с большой буквы) # → отказ. Реальный бренд бы имел уникальное название. if 2 <= len(words_in_name) <= 4 and all( w[:1].isupper() and w[1:].islower() for w in words_in_name if len(w) > 2 ): logger.debug(f" Skip (ФИО без ИНН): {name}") return None # Список запросов от самого специфичного к общему queries = [] if inn: queries.append(f'"{cleaned}" ИНН {inn} сайт') queries.append(f"{cleaned} {inn}") queries.append(f"{cleaned} официальный сайт") if not inn: # Без ИНН только специфичный запрос — без fallback на общий cleaned pass else: queries.append(cleaned) headers_base = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", } seen: set[str] = set() max_candidates = 6 # проверим не больше 6 разных сайтов for query in queries: try: url = SEARCH_URL.format(query=quote(query)) headers = {**headers_base, "User-Agent": _ua.random} r = requests.get(url, headers=headers, timeout=timeout, verify=False) except Exception as e: logger.debug(f" DDG fetch failed for {query!r}: {e}") continue if r.status_code != 200: continue for found_url in _parse_ddg_results(r.text): host = (urlparse(found_url).hostname or "").lower() if host in seen: continue seen.add(host) if not _is_company_site(found_url): continue if len(seen) > max_candidates: break root = f"{urlparse(found_url).scheme}://{host}" if not verify: return root # Верифицируем что сайт реально про эту компанию if _verify_site_belongs_to_company(root, cleaned, inn): logger.debug(f" ✓ Верифицирован: {root} (по {query!r})") return root else: logger.debug(f" ✗ Не верифицирован: {root}") # Пауза между запросами — вежливость к DDG time.sleep(random.uniform(0.6, 1.2)) return None if __name__ == "__main__": # Smoke-test — передай "имя_компании ИНН" в аргументах import sys logging.basicConfig(level=logging.INFO, format="%(message)s") cases = [ ("Кафе Пушкинъ", None), ("ООО Тануки", None), ] if len(sys.argv) > 1: cases = [(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)] for name, inn in cases: print(f"\n→ {name[:50]} (ИНН {inn})") result = find_company_website(name, inn=inn) print(f" site: {result}")