"""Поиск сайта компании по имени/ИНН — для лидов у которых нет 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 реальных (не агрегаторных) кандидатов
real_candidates = 0
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
real_candidates += 1
if real_candidates > 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}")