init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
"""Поиск сайта компании по имени/ИНН — для лидов у которых нет 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 разных сайтов
|
||||
|
||||
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}")
|
||||
Reference in New Issue
Block a user