- leadhunter_scraper.py : Google Places Nearby Search + Place Details avec compteur quota daily_quota.json (limite 900/jour), sleep(0.5) entre requêtes, fallback Overpass OSM boundary MEL, filtre website absent, déduplcation, rgpd_ok=True - leadhunter_scorer.py : moteur de scoring 0-8 pts critère n°1 = site web absent (+3), avis ≥50 (+2), note ≥4.0 (+2), téléphone (+1), note <3.0 (-1) - leadhunter_crm.py : CRM SQLite schéma validé CTO (id, source, name, address, phone, rating, reviews_count, website, score, rgpd_ok, scraped_at, status) CRUD : insert_lead, get_leads, update_lead_status, get_stats, export_csv - leadhunter_api.py : Flask service port 8769 GET /api/leads, POST /api/leads/scrape, GET /api/leads/stats, GET /api/leads/export, PATCH /api/leads/<id>/status, GET /health assert GOOGLE_PLACES_API_KEY au démarrage scraping asynchrone (thread) avec status endpoint - infra/turf-saas-leadhunter.service : service systemd EnvironmentFile=/home/h3r7/.env pour GOOGLE_PLACES_API_KEY Tests : py_compile OK, scorer testé, CRM SQLite testé Co-Authored-By: Paperclip <noreply@paperclip.ing>
194 lines
6.8 KiB
Python
194 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
H3R7Tech — LeadHunter Scorer
|
||
================================
|
||
Moteur de scoring des leads restaurants MEL.
|
||
|
||
Critères (ordre de priorité métier) :
|
||
1. [+3] Site web absent ← CRITIQUE : raison d'être du produit
|
||
2. [+2] Nombre d'avis élevé (≥ 50) : forte activité = bon prospect de vente
|
||
3. [+2] Note Google élevée (≥ 4.0) : établissement sérieux
|
||
4. [+1] Téléphone présent : facilite la prise de contact
|
||
5. [-1] Note faible (< 3.0) : risque reputationnel pour la prestation web
|
||
|
||
Score maximum théorique : 8
|
||
Score minimum : 0 (leads avec site web ne doivent pas passer ici)
|
||
|
||
Auteur: H3R7Tech Backend Engineer
|
||
Issue: HRT-66
|
||
"""
|
||
|
||
import logging
|
||
from logging.handlers import RotatingFileHandler
|
||
|
||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||
logger = logging.getLogger("leadhunter.scorer")
|
||
|
||
_handler = RotatingFileHandler(
|
||
"/home/h3r7/leadhunter.log",
|
||
maxBytes=5 * 1024 * 1024,
|
||
backupCount=3,
|
||
)
|
||
_handler.setFormatter(
|
||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||
)
|
||
logger.setLevel(logging.INFO)
|
||
if not logger.handlers:
|
||
logger.addHandler(_handler)
|
||
logger.addHandler(logging.StreamHandler())
|
||
|
||
|
||
# ─── Scorer ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class LeadScorer:
|
||
"""
|
||
Calcule le score de priorité d'un lead.
|
||
|
||
Le score sert à trier les leads dans le CRM :
|
||
- Score élevé = prospect chaud (sans site + actif + bien noté)
|
||
- Score faible = prospect froid (peut être ignoré ou traité en dernier)
|
||
"""
|
||
|
||
def _calculate_score(self, lead: dict) -> int:
|
||
"""
|
||
Calcule le score d'un lead.
|
||
|
||
Args:
|
||
lead: dict avec les champs normalisés du scraper
|
||
(name, website, rating, reviews_count, phone, ...)
|
||
|
||
Returns:
|
||
Score entier (0–8)
|
||
"""
|
||
score = 0
|
||
|
||
# ── Critère 1 : site web absent [CRITIQUE — logique métier centrale] ──
|
||
# C'est le critère n°1 : on cherche des restaurants SANS site web
|
||
# pour leur proposer une création de site à 800–1500€.
|
||
website = lead.get("website", "")
|
||
if not website or not website.strip():
|
||
score += 3
|
||
logger.debug(f"{lead.get('name')}: +3 (site web absent)")
|
||
else:
|
||
# Si le lead a un site web, score = 0 immédiatement.
|
||
# Ce cas ne devrait pas se produire (filtre scraper),
|
||
# mais on reste défensif.
|
||
logger.warning(
|
||
f"{lead.get('name')}: site web présent ({website}), "
|
||
"lead ignoré pour scoring."
|
||
)
|
||
return 0
|
||
|
||
# ── Critère 2 : nombre d'avis élevé (≥ 50) ──────────────────────────
|
||
reviews = lead.get("reviews_count")
|
||
if reviews is not None:
|
||
try:
|
||
reviews = int(reviews)
|
||
if reviews >= 50:
|
||
score += 2
|
||
logger.debug(f"{lead.get('name')}: +2 (avis ≥ 50 : {reviews})")
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning(f"reviews_count invalide pour {lead.get('name')}: {e}")
|
||
|
||
# ── Critère 3 : bonne note Google (≥ 4.0) ───────────────────────────
|
||
rating = lead.get("rating")
|
||
if rating is not None:
|
||
try:
|
||
rating = float(rating)
|
||
if rating >= 4.0:
|
||
score += 2
|
||
logger.debug(f"{lead.get('name')}: +2 (note ≥ 4.0 : {rating})")
|
||
elif rating < 3.0:
|
||
score -= 1
|
||
logger.debug(f"{lead.get('name')}: -1 (note < 3.0 : {rating})")
|
||
except (TypeError, ValueError) as e:
|
||
logger.warning(f"rating invalide pour {lead.get('name')}: {e}")
|
||
|
||
# ── Critère 4 : téléphone présent ────────────────────────────────────
|
||
phone = lead.get("phone", "")
|
||
if phone and phone.strip():
|
||
score += 1
|
||
logger.debug(f"{lead.get('name')}: +1 (téléphone présent)")
|
||
|
||
# Plancher à 0
|
||
score = max(0, score)
|
||
logger.info(f"Score calculé pour '{lead.get('name')}' : {score}/8")
|
||
return score
|
||
|
||
def score_lead(self, lead: dict) -> dict:
|
||
"""
|
||
Enrichit un lead avec son score.
|
||
|
||
Args:
|
||
lead: dict normalisé du scraper.
|
||
|
||
Returns:
|
||
Même dict avec le champ 'score' ajouté/mis à jour.
|
||
"""
|
||
lead = dict(lead) # copie défensive
|
||
lead["score"] = self._calculate_score(lead)
|
||
return lead
|
||
|
||
def score_leads(self, leads: list[dict]) -> list[dict]:
|
||
"""
|
||
Score et trie une liste de leads (score décroissant).
|
||
|
||
Args:
|
||
leads: liste de dicts normalisés.
|
||
|
||
Returns:
|
||
Liste triée par score décroissant.
|
||
"""
|
||
scored = [self.score_lead(lead) for lead in leads]
|
||
scored.sort(key=lambda l: l.get("score", 0), reverse=True)
|
||
logger.info(
|
||
f"score_leads terminé : {len(scored)} leads scorés. "
|
||
f"Score max = {scored[0]['score'] if scored else 0}, "
|
||
f"Score min = {scored[-1]['score'] if scored else 0}"
|
||
)
|
||
return scored
|
||
|
||
|
||
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
# Exemple de test rapide sans appel API
|
||
test_leads = [
|
||
{
|
||
"name": "Restaurant A",
|
||
"website": "",
|
||
"rating": 4.5,
|
||
"reviews_count": 120,
|
||
"phone": "+33 3 20 00 00 01",
|
||
},
|
||
{
|
||
"name": "Restaurant B",
|
||
"website": "",
|
||
"rating": 3.8,
|
||
"reviews_count": 30,
|
||
"phone": "",
|
||
},
|
||
{
|
||
"name": "Café C",
|
||
"website": "",
|
||
"rating": 2.5,
|
||
"reviews_count": 5,
|
||
"phone": "+33 3 20 00 00 03",
|
||
},
|
||
{
|
||
"name": "Bar D avec site",
|
||
"website": "https://bar-d.fr",
|
||
"rating": 4.2,
|
||
"reviews_count": 80,
|
||
"phone": "+33 3 20 00 00 04",
|
||
},
|
||
]
|
||
|
||
scorer = LeadScorer()
|
||
results = scorer.score_leads(test_leads)
|
||
|
||
print("\n=== Résultats scoring ===")
|
||
for r in results:
|
||
print(f" [{r['score']:2d}/8] {r['name']}")
|