Files
Aks f78f35fb3f init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:56:06 +03:00

288 lines
15 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.
"""Скоринг лидов v5 — «решаемая нами боль».
score = pain(решаемая нами боль) × icp_fit(наш ли это размер), шкала 0-10.
Принцип (решение 2026-06-01):
Score отвечает на ОДИН вопрос — есть ли у компании проблемы, которые
закрывают наши продукты, и насколько остро. Не «качество лида», не
«дозвонибельность» — только боль под продукты 44AS.
• Каждый детектор боли привязан к продукту (P-код) и теме.
• Внутри темы — насыщение (max + k·остальные): коррелированные сигналы
одной темы (несколько веб-проблем) не складываются линейно.
• ICP-гейт множителем топит крупняк/премиум (не наша ЦА).
• «Уже автоматизирован» отдельно не гейтим — у такого лида просто нет
болевых дыр, severity→0.
• Каждый детектор различает present / absent / unknown: «не проверяли»
(поле = None) НЕ засчитывается как «нет боли» — уходит в coverage.
Веса и пороги — в config (PAIN_WEIGHTS=SCORE_WEIGHTS, ICP_*, BAND_*).
calculate_score(lead) -> (score:int, breakdown:dict) — сигнатура сохранена
для обратной совместимости с database.update_score / main.run_rescore.
"""
import config
W = config.SCORE_WEIGHTS
# CMS, которые считаем «конструктором» (сайт-визитка, легко прокачать).
CONSTRUCTOR_CMS = {
"tilda", "wix", "squarespace", "webflow",
"insales", "1c-bitrix-sites", "readymag", "craftum",
# Авто-визитки (Я.Бизнес и пр.) — слабейшее веб-присутствие, тоже конструктор.
"yandex_business", "nethouse", "taplink", "ucoz",
}
def _cat_in(category: str, keywords: set) -> bool:
"""True если в названии категории встречается хоть одно ключевое слово.
Используется для категорийной релевантности детекторов (см. config:
APPT_CATEGORIES / SOCIAL_SALES_CATEGORIES).
"""
return any(k in category for k in keywords)
def _rating_key(avg: float):
"""Рейтинг → ключ детектора репутации (континуум-ступени).
None если оценок нет (не известно — не штрафуем) или рейтинг хороший (4.8+).
"""
if avg <= 0:
return None
if avg < 3.5:
return "rating_very_low"
if avg < 4.0:
return "rating_low"
if avg < 4.5:
return "rating_mid"
# 4.5+ — здоровая репутация, не наша боль (P3 AI-Reputation тут не нужен)
return None
def icp_fit(lead: dict) -> float:
"""Множитель 0..1: прогрессивный штраф за «зрелость» (отзывы × рейтинг).
Чем больше отзывов И выше рейтинг — тем сильнее снижение (процветающим мы
менее нужны). Отзывы штрафуют всегда (доля ICP_BASE), рейтинг усиливает.
Мало отзывов → множитель ≈1 (новый/борющийся бизнес сохраняет балл).
"""
rc = int(lead.get("reviews_count") or 0)
avg = float(lead.get("reviews_avg") or 0)
rf = min(1.0, rc / config.ICP_REVIEWS_FULL) if config.ICP_REVIEWS_FULL else 0.0
span = 5.0 - config.ICP_RATING_MIN
gf = max(0.0, min(1.0, (avg - config.ICP_RATING_MIN) / span)) if span > 0 else 0.0
penalty = config.ICP_PMAX * rf * (config.ICP_BASE + (1 - config.ICP_BASE) * gf)
return 1.0 - penalty
def _detect_pain(lead: dict):
"""Прогнать детекторы боли.
Возвращает (themes, products, reasons, covered):
themes: theme -> list[severity]
products: product(P-код) -> сумма severity (для «с чем заходить»)
reasons: list[str] человекочитаемых причин
covered: theme -> bool (была ли тема диагностирована — для coverage)
"""
themes: dict[str, list[float]] = {}
products: dict[str, float] = {}
reasons: list[str] = []
def emit(key: str) -> None:
sev = W[key]
themes.setdefault(config.PAIN_THEME[key], []).append(sev)
prod = config.PAIN_PRODUCT[key]
products[prod] = products.get(prod, 0.0) + sev
reasons.append(config.PAIN_REASON[key])
hob = lead.get("has_online_booking")
hlc = lead.get("has_live_chat")
site_alive = lead.get("site_alive")
has_analytics = lead.get("has_analytics")
cms = (lead.get("cms_type") or "").lower()
website = lead.get("website")
edt = lead.get("email_domain_type")
rc = int(lead.get("reviews_count") or 0)
avg = float(lead.get("reviews_avg") or 0)
# Полнота диагностики по темам: тема «покрыта», если есть её источник.
covered = {
"booking": (hob is not None) or (hlc is not None), # Tier 2 прошёл
"reputation": rc > 0 or avg > 0, # отзывы из Я.Карт
"web": (site_alive is not None) or bool(website) or lead.get("has_website") == 1,
"marketing": True, # соцсети известны всегда
"infra": edt is not None,
}
# ── booking / inbound (P4) ───────────────────────────────────────
# Категорийная релевантность (см. config): «нет онлайн-записи» — боль
# только для услуг с записью (APPT_CATEGORIES). Розница/B2B (магазин,
# бухгалтерия) запись не ведут → не штрафуем. Чат релевантен всем.
cat = (lead.get("category") or "").lower()
if hob == 0 and _cat_in(cat, config.APPT_CATEGORIES):
emit("no_online_booking")
if hlc == 0:
emit("no_live_chat")
# ── reputation (P3) ──────────────────────────────────────────────
rk = _rating_key(avg)
if rk:
emit(rk)
if 0 < rc < 10:
emit("few_reviews")
elif 10 <= rc < 30:
emit("some_reviews")
# ── web (P10) ────────────────────────────────────────────────────
# «Есть сайт» определяем по совокупности сигналов, НЕ только по URL-полю:
# у ~30% лидов website-колонка пустая, хотя сайт реально проверен
# (has_website=1 / site_alive=1 / cms заполнен — URL потерялся в пайплайне).
# Иначе «нет сайта» ложно срабатывает у тех, у кого сайт есть и работает.
has_site = bool(website) or lead.get("has_website") == 1 or site_alive == 1
if site_alive == 0:
emit("site_dead")
elif not has_site:
emit("no_website")
elif cms in CONSTRUCTOR_CMS:
# Премиум-на-Tilda отсекается не здесь, а ICP-гейтом (×0.2 по отзывам) —
# авто-визитка/конструктор это слабый сайт независимо от популярности.
emit("site_constructor")
# ── marketing (P12) ──────────────────────────────────────────────
# «Нет соцсетей» — боль только для B2C, где соцсети = канал продаж
# (SOCIAL_SALES_CATEGORIES). Для B2B (бухгалтерия, стройка) — не боль.
# Аналитика на сайте — универсальна.
# Instagram в РФ забанен — не учитываем. Соцсети = VK / Telegram.
no_social = not lead.get("vk_url") and not lead.get("telegram_url")
if no_social and _cat_in(cat, config.SOCIAL_SALES_CATEGORIES):
emit("no_social")
if has_analytics == 0:
emit("no_analytics")
# ── infra (P2) ───────────────────────────────────────────────────
if edt == "free":
emit("free_email")
return themes, products, reasons, covered
def _saturate(severities: list[float]) -> float:
"""max + k·(сумма остальных) — насыщение внутри темы."""
if not severities:
return 0.0
s = sorted(severities, reverse=True)
return s[0] + config.THEME_SATURATION * sum(s[1:])
def band(score: int) -> str:
"""Бэнд лида по score: hot / warm / cold."""
if score >= config.BAND_HOT:
return "hot"
if score >= config.BAND_WARM:
return "warm"
return "cold"
def calculate_score(lead: dict) -> tuple[int, dict]:
"""score = pain × icp, шкала 0-10. Возвращает (score, breakdown:dict).
breakdown — богатая структура (JSON-сериализуемая) для CRM и аудита:
band, icp_fit, pain_raw, coverage, themes, pain_products, reasons.
"""
themes, products, reasons, covered = _detect_pain(lead)
theme_values = {t: round(_saturate(s), 2) for t, s in themes.items()}
raw_pain = sum(theme_values.values())
icp = icp_fit(lead)
raw_final = raw_pain * icp
score = min(round(raw_final / config.PAIN_NORM * config.SCORE_MAX), config.SCORE_MAX)
coverage = round(sum(1 for v in covered.values() if v) / len(covered), 2)
# «С чем заходить» — продукты по убыванию суммарной severity.
pain_products = {
p: round(v, 1)
for p, v in sorted(products.items(), key=lambda kv: -kv[1])
}
breakdown = {
"v": 5,
"band": band(score),
"icp_fit": round(icp, 2),
"pain_raw": round(raw_pain, 2),
"coverage": coverage,
"themes": theme_values,
"pain_products": pain_products,
"reasons": reasons,
}
return score, breakdown
def annotate_with_score(lead: dict) -> dict:
"""Проставить lead['score'] и lead['score_breakdown'] (in-place + возврат)."""
score, breakdown = calculate_score(lead)
lead["score"] = score
lead["score_breakdown"] = breakdown
return lead
# ───────────────────────────────────────────────────────────────────────
# Smoke-тесты v5
# ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
# 1. Малый сервисный бизнес с дырами — должен быть hot, с понятным «зайти»
small_pain = {
"name": "Барбершоп у Лёхи", "category": "барбершоп",
"reviews_avg": 4.4, "reviews_count": 120,
"website": "https://barber.tilda.ws/", "site_alive": 1,
"cms_type": "tilda", "has_live_chat": 0, "has_online_booking": 0,
"has_analytics": 0, "vk_url": None, "telegram_url": None,
"instagram_url": None, "email_domain_type": "free",
"source": "yandex_maps",
}
s, b = calculate_score(small_pain)
print(f"1. Малый с дырами: score={s} band={b['band']} products={b['pain_products']}")
assert s >= config.BAND_HOT, "малый бизнес с дырами должен быть hot"
# 2. Премиум-ресторан (тысячи отзывов + 5.0) — должен утонуть несмотря на дыры
premium = {
"name": "White Rabbit", "category": "ресторан",
"reviews_avg": 5.0, "reviews_count": 16817,
"website": "https://whiterabbit.tilda.ws/", "site_alive": 1,
"cms_type": "tilda", "has_live_chat": 0, "has_online_booking": 0,
"has_analytics": 1, "telegram_url": "https://t.me/wr",
"source": "yandex_maps",
}
s, b = calculate_score(premium)
print(f"2. Премиум: score={s} band={b['band']} icp={b['icp_fit']}")
assert s < config.BAND_WARM, "премиум должен проваливаться даже с дырами"
# 3. Уже автоматизирован (запись+чат, нормальный сайт, рейтинг ок) — cold
equipped = {
"name": "Клиника Здоровье", "category": "стоматология",
"reviews_avg": 4.9, "reviews_count": 800,
"website": "https://clinic.ru/", "site_alive": 1,
"cms_type": "bitrix", "has_live_chat": 1, "has_online_booking": 1,
"has_analytics": 1, "vk_url": "https://vk.com/clinic",
"email_domain_type": "corporate", "source": "yandex_maps",
}
s, b = calculate_score(equipped)
print(f"3. Оснащённый: score={s} band={b['band']} products={b['pain_products']}")
assert s < config.BAND_WARM, "оснащённый бизнес = нет боли = cold"
# 4. Свежий лид с источника (Tier 2 ещё не прошёл) — coverage низкая
fresh = {
"name": "Кафе только спарсили", "category": "кафе",
"reviews_avg": 4.3, "reviews_count": 40,
"website": None, "source": "yandex_maps",
}
s, b = calculate_score(fresh)
print(f"4. Свежий (без Tier2): score={s} coverage={b['coverage']}")
assert b["coverage"] < 1.0, "недиагностированный лид должен иметь coverage<1"
print(
f"\n[OK] scoring.py v5 — smoke-тесты пройдены. "
f"BAND_HOT={config.BAND_HOT}, PAIN_NORM={config.PAIN_NORM}, SCORE_MAX={config.SCORE_MAX}"
)