98309dcc96
- SQL injection паттерн → параметризованные запросы во всех местах - except: pass/continue → logger.warning() везде, ничего не тонет молча - WAL mode + индекс domain_dedup_key в database.py - try/finally для conn в main.py, утечка соединения устранена - backoff 30с при 403/429 от Rusprofile/ЕГРЮЛ - ликвидированные компании → egrul_status="liquidated" - max_candidates в contacts_finder считает только реальных кандидатов - DB_PATH абсолютный (Path(__file__).parent), HH_PAUSE_BETWEEN_QUERIES в config - HH_SIGNAL_QUERIES дубль убран из launcher.py → импорт из config - path traversal защита в egrul_enricher debug_dump_html Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
364 lines
16 KiB
Python
364 lines
16 KiB
Python
"""Поиск сайта компании по имени/ИНН — для лидов у которых нет 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: <a class="result__a" href="//duckduckgo.com/l/?uddg=URL_ENCODED">
|
||
Декодируем uddg= и возвращаем реальный URL.
|
||
"""
|
||
urls: list[str] = []
|
||
# Главный паттерн (с uddg-обёрткой)
|
||
for m in re.finditer(
|
||
r'<a[^>]+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'<a[^>]+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}")
|