Files
Aks 98309dcc96 fix: устранены все найденные аудитом баги и тихие падения
- 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>
2026-06-09 13:19:52 +03:00

364 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Поиск сайта компании по имени/ИНН — для лидов у которых нет 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}")