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:
+287
@@ -0,0 +1,287 @@
|
||||
"""Скоринг лидов 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user