Port 8769 était occupé par /home/h3r7/depenses_trello/app.py (pid=2287989). Mise à jour du port dans : - leadhunter_api.py (docstring, healthcheck, app.run) - infra/turf-saas-leadhunter.service (description) Ref: HRT-66 Co-Authored-By: Paperclip <noreply@paperclip.ing>
304 lines
9.4 KiB
Python
304 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
H3R7Tech — LeadHunter API
|
|
===========================
|
|
Service Flask sur port 8770 exposant les endpoints LeadHunter.
|
|
|
|
Endpoints :
|
|
GET /api/leads — Liste les leads (filtres: status, limit, offset)
|
|
POST /api/leads/scrape — Lance un job de scraping asynchrone
|
|
GET /api/leads/stats — Statistiques globales du CRM
|
|
GET /api/leads/export — Export CSV des leads
|
|
PATCH /api/leads/<id>/status — Met à jour le statut d'un lead
|
|
|
|
Port : 8770 (8769 occupé par depenses_trello/app.py — corrigé HRT-66)
|
|
|
|
Auteur: H3R7Tech Backend Engineer
|
|
Issue: HRT-66
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
import logging
|
|
from logging.handlers import RotatingFileHandler
|
|
from flask import Flask, jsonify, request, Response
|
|
from flask_cors import CORS
|
|
|
|
# Import des modules LeadHunter
|
|
from leadhunter_crm import (
|
|
init_db,
|
|
insert_leads,
|
|
get_leads,
|
|
get_lead_by_id,
|
|
update_lead_status,
|
|
get_stats,
|
|
export_csv,
|
|
VALID_STATUSES,
|
|
DB_PATH,
|
|
)
|
|
from leadhunter_scraper import run_scraping, GOOGLE_PLACES_API_KEY
|
|
from leadhunter_scorer import LeadScorer
|
|
|
|
# ─── Assertions au démarrage ─────────────────────────────────────────────────
|
|
# Vérification obligatoire : la clé API doit être présente au démarrage
|
|
assert os.environ.get("GOOGLE_PLACES_API_KEY"), (
|
|
"GOOGLE_PLACES_API_KEY manquante. "
|
|
"Ajouter dans /home/h3r7/.env : export GOOGLE_PLACES_API_KEY=xxx"
|
|
)
|
|
|
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
|
logger = logging.getLogger("leadhunter.api")
|
|
|
|
_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())
|
|
|
|
# ─── App Flask ───────────────────────────────────────────────────────────────
|
|
app = Flask(__name__)
|
|
CORS(app)
|
|
|
|
# Scorer singleton
|
|
scorer = LeadScorer()
|
|
|
|
# État global du job de scraping (simple flag — pas de celery nécessaire pour le POC)
|
|
_scrape_job = {
|
|
"running": False,
|
|
"last_run": None,
|
|
"last_count": 0,
|
|
"last_error": None,
|
|
}
|
|
_scrape_lock = threading.Lock()
|
|
|
|
# ─── Init DB ─────────────────────────────────────────────────────────────────
|
|
init_db(DB_PATH)
|
|
logger.info("LeadHunter API démarrée — DB initialisée.")
|
|
|
|
|
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _run_scrape_job(max_leads: int, use_google: bool, use_osm: bool) -> None:
|
|
"""Job de scraping exécuté dans un thread séparé."""
|
|
with _scrape_lock:
|
|
_scrape_job["running"] = True
|
|
_scrape_job["last_error"] = None
|
|
|
|
try:
|
|
leads_raw = run_scraping(
|
|
max_leads=max_leads,
|
|
use_google=use_google,
|
|
use_osm=use_osm,
|
|
)
|
|
leads_scored = scorer.score_leads(leads_raw)
|
|
inserted_ids = insert_leads(leads_scored)
|
|
|
|
with _scrape_lock:
|
|
_scrape_job["last_count"] = len(inserted_ids)
|
|
from datetime import datetime
|
|
|
|
_scrape_job["last_run"] = datetime.utcnow().isoformat() + "Z"
|
|
|
|
logger.info(f"Scrape job terminé : {len(inserted_ids)} leads insérés.")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Scrape job erreur : {e}")
|
|
with _scrape_lock:
|
|
_scrape_job["last_error"] = str(e)
|
|
|
|
finally:
|
|
with _scrape_lock:
|
|
_scrape_job["running"] = False
|
|
|
|
|
|
# ─── Routes ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.route("/api/leads", methods=["GET"])
|
|
def api_get_leads():
|
|
"""
|
|
Liste les leads du CRM.
|
|
|
|
Query params :
|
|
- status (str, optional) : filtre sur new/contacted/closed/rejected
|
|
- limit (int, default=50) : pagination
|
|
- offset (int, default=0) : pagination
|
|
"""
|
|
status = request.args.get("status")
|
|
try:
|
|
limit = int(request.args.get("limit", 50))
|
|
offset = int(request.args.get("offset", 0))
|
|
except ValueError:
|
|
return jsonify({"error": "limit et offset doivent être des entiers"}), 400
|
|
|
|
if status and status not in VALID_STATUSES:
|
|
return jsonify(
|
|
{"error": f"status invalide. Valeurs acceptées : {VALID_STATUSES}"}
|
|
), 400
|
|
|
|
leads = get_leads(status=status, limit=limit, offset=offset)
|
|
return jsonify(
|
|
{
|
|
"leads": leads,
|
|
"count": len(leads),
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"status_filter": status,
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/api/leads/scrape", methods=["POST"])
|
|
def api_scrape():
|
|
"""
|
|
Lance un job de scraping asynchrone.
|
|
|
|
Body JSON (optionnel) :
|
|
- max_leads (int, default=100)
|
|
- use_google (bool, default=true)
|
|
- use_osm (bool, default=true)
|
|
|
|
Retourne immédiatement avec le statut du job.
|
|
"""
|
|
with _scrape_lock:
|
|
if _scrape_job["running"]:
|
|
return jsonify(
|
|
{
|
|
"status": "already_running",
|
|
"message": "Un job de scraping est déjà en cours.",
|
|
}
|
|
), 409
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
max_leads = int(body.get("max_leads", 100))
|
|
use_google = bool(body.get("use_google", True))
|
|
use_osm = bool(body.get("use_osm", True))
|
|
|
|
thread = threading.Thread(
|
|
target=_run_scrape_job,
|
|
args=(max_leads, use_google, use_osm),
|
|
daemon=True,
|
|
)
|
|
thread.start()
|
|
|
|
logger.info(
|
|
f"Job de scraping lancé (max_leads={max_leads}, "
|
|
f"use_google={use_google}, use_osm={use_osm})"
|
|
)
|
|
|
|
return jsonify(
|
|
{
|
|
"status": "started",
|
|
"message": "Job de scraping démarré en arrière-plan.",
|
|
"params": {
|
|
"max_leads": max_leads,
|
|
"use_google": use_google,
|
|
"use_osm": use_osm,
|
|
},
|
|
}
|
|
), 202
|
|
|
|
|
|
@app.route("/api/leads/scrape/status", methods=["GET"])
|
|
def api_scrape_status():
|
|
"""Retourne l'état courant du job de scraping."""
|
|
with _scrape_lock:
|
|
return jsonify(dict(_scrape_job))
|
|
|
|
|
|
@app.route("/api/leads/stats", methods=["GET"])
|
|
def api_stats():
|
|
"""
|
|
Statistiques globales du CRM LeadHunter.
|
|
|
|
Retourne : total, by_status, by_source, avg_score, top_leads_count
|
|
"""
|
|
stats = get_stats()
|
|
if not stats:
|
|
return jsonify({"error": "Impossible de calculer les statistiques"}), 500
|
|
return jsonify(stats)
|
|
|
|
|
|
@app.route("/api/leads/export", methods=["GET"])
|
|
def api_export():
|
|
"""
|
|
Export CSV de tous les leads (ou filtrés par status).
|
|
|
|
Query params :
|
|
- status (str, optional)
|
|
"""
|
|
status = request.args.get("status")
|
|
if status and status not in VALID_STATUSES:
|
|
return jsonify({"error": f"status invalide : {VALID_STATUSES}"}), 400
|
|
|
|
csv_content = export_csv(status=status)
|
|
filename = f"leadhunter_leads{'_' + status if status else ''}.csv"
|
|
|
|
return Response(
|
|
csv_content,
|
|
mimetype="text/csv",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={filename}",
|
|
"Content-Type": "text/csv; charset=utf-8",
|
|
},
|
|
)
|
|
|
|
|
|
@app.route("/api/leads/<int:lead_id>/status", methods=["PATCH"])
|
|
def api_update_status(lead_id: int):
|
|
"""
|
|
Met à jour le statut d'un lead.
|
|
|
|
Body JSON :
|
|
- status (str) : new | contacted | closed | rejected
|
|
"""
|
|
body = request.get_json(silent=True)
|
|
if not body or "status" not in body:
|
|
return jsonify({"error": "Body JSON requis avec le champ 'status'"}), 400
|
|
|
|
new_status = body["status"]
|
|
if new_status not in VALID_STATUSES:
|
|
return jsonify({"error": f"status invalide. Valeurs : {VALID_STATUSES}"}), 400
|
|
|
|
lead = get_lead_by_id(lead_id)
|
|
if not lead:
|
|
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
|
|
|
success = update_lead_status(lead_id, new_status)
|
|
if not success:
|
|
return jsonify({"error": "Mise à jour échouée"}), 500
|
|
|
|
return jsonify(
|
|
{
|
|
"success": True,
|
|
"lead_id": lead_id,
|
|
"new_status": new_status,
|
|
}
|
|
)
|
|
|
|
|
|
@app.route("/health", methods=["GET"])
|
|
def health():
|
|
"""Healthcheck pour systemd / monitoring."""
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"service": "leadhunter-api",
|
|
"port": 8770,
|
|
}
|
|
)
|
|
|
|
|
|
# ─── Entrypoint ──────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=8770, debug=False)
|