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:
Aks
2026-06-09 12:56:06 +03:00
commit f78f35fb3f
33 changed files with 9198 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
"""Аудит скоринга — независимая проверка всей leads.db.
Запуск: python audit_scores.py (из папки parser_v1)
Что делает:
1. Пересчитывает score каждого лида из сырых полей (scoring.calculate_score)
и сверяет со значением в БД — ловит «устаревший» score / band / pain_products.
2. Прогоняет САНИТАРНЫЕ правила: боль не должна противоречить данным
(напр. причина «нет сайта», а сайт есть). Это потенциальные баги скоринга.
3. Прогоняет правила ЦЕЛОСТНОСТИ ДАННЫХ (has_website=1 но website пуст и т.п.).
4. Собирает очередь НА РУЧНОЙ ВЗГЛЯД (не баги — пограничные: hot при низкой
полноте диагностики, «процветающие» в hot).
Только чтение БД. Ничего не пишет.
Выход: код 1 если найдены HARD-аномалии (для CI/хука), иначе 0.
"""
import json
import sqlite3
import sys
sys.path.insert(0, ".")
import config
import scoring
DB = "leads.db"
CONSTRUCTORS = scoring.CONSTRUCTOR_CMS
def _pp(s):
try:
return json.loads(s or "{}")
except (ValueError, TypeError):
return {}
def main():
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
rows = con.execute("SELECT * FROM leads").fetchall()
con.close()
hard = [] # (rule, lead_id, name, detail) — противоречия = баги
soft = [] # (rule, lead_id, name, detail) — на ручной взгляд
for r in rows:
L = dict(r)
lid, name = L["id"], (L.get("name") or "")[:28]
cat = (L.get("category") or "").lower()
avg = float(L.get("reviews_avg") or 0)
rc = int(L.get("reviews_count") or 0)
# ── 1. Пересчёт и сверка со стором ───────────────────────────
rescore, breakdown = scoring.calculate_score(L)
reasons = breakdown["reasons"]
if (L.get("score") or 0) != rescore:
hard.append(("STALE_SCORE", lid, name, f"в БД {L.get('score')} != пересчёт {rescore} (нужен --rescore)"))
if L.get("band") and L["band"] != breakdown["band"]:
hard.append(("BAND_MISMATCH", lid, name, f"в БД {L['band']} != {breakdown['band']}"))
if _pp(L.get("pain_products")) != breakdown["pain_products"]:
hard.append(("PAIN_STALE", lid, name, "pain_products в БД != пересчёт"))
# ── 2. Санитарные правила: боль vs данные ────────────────────
if "нет сайта" in reasons and (L.get("website") or L.get("has_website") == 1 or L.get("site_alive") == 1):
hard.append(("FALSE_NO_SITE", lid, name, f"боль 'нет сайта', но website={L.get('website')} has_website={L.get('has_website')} alive={L.get('site_alive')}"))
if "нет соцсетей (VK/Telegram)" in reasons and (L.get("vk_url") or L.get("telegram_url")):
hard.append(("FALSE_NO_SOCIAL", lid, name, f"боль 'нет соцсетей', но vk={L.get('vk_url')} tg={L.get('telegram_url')}"))
if "сайт на конструкторе" in reasons and (L.get("cms_type") or "").lower() not in CONSTRUCTORS:
hard.append(("FALSE_CONSTRUCTOR", lid, name, f"боль 'конструктор', но cms={L.get('cms_type')}"))
if any("рейтинг" in x for x in reasons) and avg >= 4.5:
hard.append(("FALSE_RATING", lid, name, f"боль по рейтингу, но avg={avg}"))
if "нет онлайн-записи" in reasons and not scoring._cat_in(cat, config.APPT_CATEGORIES):
hard.append(("BOOKING_WRONG_CAT", lid, name, f"'нет онлайн-записи' у не-сервисной категории '{cat}'"))
if "нет соцсетей (VK/Telegram)" in reasons and not scoring._cat_in(cat, config.SOCIAL_SALES_CATEGORIES):
hard.append(("SOCIAL_WRONG_CAT", lid, name, f"'нет соцсетей' у не-B2C категории '{cat}'"))
# ── 3. Целостность данных ────────────────────────────────────
if L.get("has_website") == 1 and not L.get("website"):
hard.append(("WEBSITE_LOST", lid, name, "has_website=1, но website пуст (потеря URL)"))
if L.get("site_alive") == 1 and L.get("has_online_booking") is None:
soft.append(("PARTIAL_ENRICH", lid, name, "site_alive=1, но has_online_booking не проверен"))
if rc > 0 and avg == 0:
soft.append(("REVIEWS_NO_AVG", lid, name, f"reviews_count={rc}, но avg=0"))
if not name:
hard.append(("NO_NAME", lid, name, "пустое имя"))
# ── 4. На ручной взгляд (пограничное, не баг) ────────────────
cov = L.get("diagnostic_coverage")
band = breakdown["band"]
if band == "hot" and cov is not None and cov < config.MIN_COVERAGE:
soft.append(("HOT_LOW_COVERAGE", lid, name, f"hot при coverage={cov} (тонкие данные)"))
if rescore > 0 and not breakdown["pain_products"]:
hard.append(("SCORE_NO_PAIN", lid, name, f"score={rescore}, но pain_products пуст"))
# ── Отчёт ────────────────────────────────────────────────────────
print(f"АУДИТ СКОРИНГА — {len(rows)} лидов\n" + "=" * 60)
def group(items, title):
from collections import defaultdict
by = defaultdict(list)
for rule, lid, nm, det in items:
by[rule].append((lid, nm, det))
if not items:
print(f"\n{title}: чисто ✅")
return
print(f"\n{title}: {len(items)} (правил: {len(by)})")
for rule, lst in by.items():
print(f"{rule}: {len(lst)}")
for lid, nm, det in lst[:5]:
print(f" #{lid} {nm}{det}")
if len(lst) > 5:
print(f" … ещё {len(lst) - 5}")
group(hard, "🔴 HARD — противоречия (баги)")
group(soft, "🟡 SOFT — на ручной взгляд")
print("\n" + "=" * 60)
if hard:
print(f"ИТОГ: ⚠️ {len(hard)} HARD-аномалий — есть что чинить.")
return 1
print("ИТОГ: ✅ HARD-аномалий нет — скоринг консистентен с данными.")
return 0
if __name__ == "__main__":
sys.exit(main())