Files
trendyol-analiz/backend/analytics/champion_finder.py
furkanyigit34 ce1dc1e25f feat: tek birleştirilmiş JSON yapısına geçiş + sosyal kanıt fallback
Ne yaptık:
- data_consolidator.py: Tüm normalizasyon ve hesaplama mantığını main.py'den çıkardık
- Dashboard endpoint 1150 satırdan 25 satıra düştü (main.py -1730/+1880 net)
- Enrichment bitince otomatik konsolide dosya oluşturuluyor (report_{id}_data.json)
- Eski raporlar ilk dashboard isteğinde lazy migration ile konsolide ediliyor
- Trendyol API artık order-count döndürmediği için baskets fallback eklendi
- Inline socialProofs (scrape) > enrichment API öncelik sırası uygulandı
- Frontend KPI başlıkları orders/baskets durumuna göre dinamik değişiyor
- logging_config.py, category_seeder.py, alembic migration eklendi
- Playwright ile 9 tab test edildi, tüm veriler doğru

Neden yaptık:
- 3 farklı kaynaktan her istekte birleştirme yapılması veri tutarsızlığına ve yavaşlığa yol açıyordu
- Tek konsolide JSON dosyası ile dashboard anında yükleniyor
- Trendyol API değişikliği nedeniyle sipariş verisi kayboluyordu, baskets fallback ile çözüldü

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:25:25 +03:00

374 lines
16 KiB
Python
Raw 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.
"""
Hidden Champion Finder - Gizli şampiyon bulucu
Özelleştirilmiş filtreler ile fırsat ürünlerini bulur
"""
from typing import Dict, List, Any, Optional
from collections import defaultdict
from .metrics import (
get_rating_value,
get_review_count,
calculate_potential_score
)
class HiddenChampionFinder:
"""
Gizli şampiyonları bulan sınıf
Parçalı pazarlarda (düşük HHI) özelleştirilmiş filtreler kullanır
"""
@staticmethod
def _parse_social_proof_value(value_str: str) -> int:
"""Parse '3k', '248k', '1.2k', '866' gibi değerleri sayıya çevir"""
if not value_str:
return 0
value_str = str(value_str).strip().lower().replace(".", "")
if value_str.endswith("k"):
try:
return int(float(value_str[:-1]) * 1000)
except (ValueError, TypeError):
return 0
if value_str.endswith("m"):
try:
return int(float(value_str[:-1]) * 1000000)
except (ValueError, TypeError):
return 0
try:
return int(value_str)
except (ValueError, TypeError):
return 0
@staticmethod
def _extract_social_proofs(product: Dict) -> Dict[str, int]:
"""Ürünün socialProofs array'inden veri çıkar"""
result = {"page_views": 0, "orders": 0, "baskets": 0, "favorites": 0}
social_proofs = product.get("socialProofs", [])
if not social_proofs:
return result
type_map = {
"pageViewCount": "page_views",
"orderCountL3D": "orders",
"orderCountL365D": "orders",
"basketCount": "baskets",
"favoriteCount": "favorites",
}
for sp in social_proofs:
sp_type = sp.get("type", "")
mapped = type_map.get(sp_type)
if mapped:
val = HiddenChampionFinder._parse_social_proof_value(sp.get("value", "0"))
# Daha büyük değeri al (orderCountL3D vs orderCountL365D)
if val > result[mapped]:
result[mapped] = val
return result
def find(
self,
products: List[Dict],
social_data: Dict,
filters: Optional[Dict] = None
) -> Dict[str, Any]:
"""
Gizli şampiyonları bul (async)
Özelleştirilmiş Filtreler:
- Rating >= 4.6 (yüksek kalite)
- Review count < 30 (henüz keşfedilmemiş)
- Social proof (views/baskets) kategorinin 2 katı üzerinde
Args:
products: Ürün listesi
social_data: Sosyal kanıt verileri
filters: Filtreleme kriterleri
Returns:
Gizli şampiyon listesi
"""
if filters is None:
filters = {
"min_rating": 4.6,
"max_review_count": 30,
"social_multiplier": 2.0, # Kategori ortalamasının 2 katı
"min_score": 70,
"limit": 50
}
# Kategori bazlı ürün sayıları (competition level için)
category_counts = defaultdict(int)
category_products = defaultdict(list)
for p in products:
category = p.get("category", {}).get("name", "Unknown")
if isinstance(category, dict):
category = category.get("name", "Unknown")
category_counts[category] += 1
category_products[category].append(p)
# Kategori bazlı ortalama social proof hesapla
category_avg_social = {}
social_details = social_data.get("details", {})
for category, cat_products in category_products.items():
total_views = 0
total_baskets = 0
count = 0
for product in cat_products:
pid = str(product.get("id"))
if pid in social_details:
views = social_details[pid].get("page_views", 0) or 0
baskets = social_details[pid].get("baskets", 0) or 0
if views > 0:
total_views += views
total_baskets += baskets
count += 1
if count > 0:
category_avg_social[category] = {
"avg_views": total_views / count,
"avg_baskets": total_baskets / count
}
else:
category_avg_social[category] = {
"avg_views": 0,
"avg_baskets": 0
}
hidden_champions = []
for product in products:
# Temel veriler
rating = get_rating_value(product)
review_count = get_review_count(product)
pid = str(product.get("id"))
social = social_details.get(pid, {})
# Önce enriched social data, sonra ürünün kendi socialProofs'u
embedded_social = self._extract_social_proofs(product)
page_views = social.get("page_views", 0) or embedded_social["page_views"] or 0
orders = social.get("orders", 0) or embedded_social["orders"] or product.get("orders", 0) or 0
baskets = social.get("baskets", 0) or embedded_social["baskets"] or 0
favorites = social.get("favorites", 0) or embedded_social["favorites"] or 0
conversion_rate = (orders / page_views * 100) if page_views > 0 else 0
# Kategori bilgisi
category = product.get("category", {})
if isinstance(category, dict):
category_name = category.get("name", "Unknown")
else:
category_name = category if category else "Unknown"
# Competition level
category_count = category_counts.get(category_name, 0)
if category_count < 100:
competition_level = "low"
elif category_count < 500:
competition_level = "medium"
else:
competition_level = "high"
# Kategori ortalaması ile karşılaştır
cat_avg = category_avg_social.get(category_name, {"avg_views": 0, "avg_baskets": 0})
threshold_views = cat_avg["avg_views"] * filters.get("social_multiplier", 2.0)
threshold_baskets = cat_avg["avg_baskets"] * filters.get("social_multiplier", 2.0)
# Eğer kategori ortalaması 0 veya çok düşükse, minimum threshold kullan
min_views_threshold = 100 # Minimum görüntülenme
min_baskets_threshold = 5 # Minimum sepet
# Threshold'ları ayarla
if threshold_views < min_views_threshold:
threshold_views = min_views_threshold
if threshold_baskets < min_baskets_threshold:
threshold_baskets = min_baskets_threshold
# Minimum Orders kontrolü (satış verisi çok önemli)
min_orders = filters.get("min_orders", 1) # Varsayılan: en az 1 satış
# Sosyal veri var mı kontrol et
has_social = pid in social_details and page_views > 0
# Özelleştirilmiş Filtreleme (daha esnek)
if has_social:
# Sosyal verisi olan ürünler: tam filtre
passes_filter = (
rating >= filters.get("min_rating", 4.6) and
review_count < filters.get("max_review_count", 30) and
review_count >= 1 and
orders >= min_orders and
(page_views >= threshold_views or page_views >= min_views_threshold) and
(baskets >= threshold_baskets or baskets >= min_baskets_threshold) and
(conversion_rate >= 1.0 or page_views >= 500)
)
else:
# Sosyal verisi olmayan ürünler: sadece rating + review + orders filtresi
passes_filter = (
rating >= filters.get("min_rating", 4.6) and
review_count < filters.get("max_review_count", 30) and
review_count >= 1 and
orders >= min_orders
)
if passes_filter:
# Potential score hesapla
potential_score = calculate_potential_score(
page_views, orders, review_count, conversion_rate, competition_level
)
# Hidden champion score hesapla
hidden_champion_score = self._calculate_hidden_champion_score(
rating, potential_score, conversion_rate, competition_level,
page_views, threshold_views, baskets, threshold_baskets
)
# Min score kontrolü
if hidden_champion_score >= filters.get("min_score", 70):
# Trendyol linki oluştur
product_url = product.get("url", "")
if product_url:
# URL relative ise (örn: /kumtel/urun-p-123), başına domain ekle
if product_url.startswith("/"):
product_url = f"https://www.trendyol.com{product_url}"
elif not product_url.startswith("http"):
product_url = f"https://www.trendyol.com{product_url}"
elif product.get("id"):
# Eğer url yoksa, product_id'den oluştur
product_url = f"https://www.trendyol.com/urun/{product.get('id')}"
else:
product_url = ""
# Görsel URL'i al (imageUrl veya images array'inden ilk eleman)
image_url = product.get("imageUrl", "")
if not image_url:
# images array'inden ilk elemanı al
images = product.get("images", [])
if images and len(images) > 0:
image_url = images[0] if isinstance(images[0], str) else str(images[0])
# Eğer hala boşsa, boş string olarak bırak
if not image_url:
image_url = ""
hidden_champions.append({
"product_id": product.get("id"),
"name": product.get("name", ""),
"brand": product.get("brand", {}).get("name", "Unknown"),
"category": category_name,
"rating": round(rating, 2),
"review_count": review_count,
"price": (product.get("price", {}).get("sellingPrice", 0) or product.get("price", {}).get("discountedPrice", 0) or product.get("price", {}).get("current", 0)) if isinstance(product.get("price"), dict) else (product.get("price", 0) or 0),
"page_views": page_views,
"orders": orders,
"baskets": baskets,
"favorites": favorites,
"conversion_rate": round(conversion_rate, 2),
"competition_level": competition_level,
"potential_score": potential_score,
"hidden_champion_score": hidden_champion_score,
"image": image_url, # Sosyal kanıt sekmesiyle uyumlu olması için "image" kullan
"image_url": image_url, # Geriye dönük uyumluluk için
"url": product_url, # Sosyal kanıt sekmesiyle uyumlu olması için "url" kullan
"product_url": product_url, # Geriye dönük uyumluluk için
"social_performance": {
"views_vs_category_avg": round((page_views / cat_avg["avg_views"]) if cat_avg["avg_views"] > 0 else 0, 2),
"baskets_vs_category_avg": round((baskets / cat_avg["avg_baskets"]) if cat_avg["avg_baskets"] > 0 else 0, 2),
"category_avg_views": round(cat_avg["avg_views"], 0),
"category_avg_baskets": round(cat_avg["avg_baskets"], 0)
}
})
# Skora göre sırala
hidden_champions.sort(key=lambda x: x["hidden_champion_score"], reverse=True)
# Limit
result = hidden_champions[:filters.get("limit", 50)]
return {
"total_found": len(hidden_champions),
"hidden_champions": result,
"summary": {
"avg_score": round(sum(hc["hidden_champion_score"] for hc in result) / len(result), 2) if result else 0,
"avg_rating": round(sum(hc["rating"] for hc in result) / len(result), 2) if result else 0,
"avg_conversion": round(sum(hc["conversion_rate"] for hc in result) / len(result), 2) if result else 0,
"low_competition_count": len([hc for hc in result if hc["competition_level"] == "low"]),
"avg_social_performance": round(
sum(hc["social_performance"]["views_vs_category_avg"] for hc in result) / len(result), 2
) if result else 0
},
"filters_applied": filters
}
def _calculate_hidden_champion_score(
self,
rating: float,
potential_score: float,
conversion_rate: float,
competition_level: str,
page_views: int,
threshold_views: float,
baskets: int,
threshold_baskets: float
) -> float:
"""
Gizli şampiyon skoru hesapla (özelleştirilmiş)
Formül:
- Rating skoru: 30 puan (4.6+ = 30, 4.8+ = 35)
- Potential score: 25 puan
- Conversion rate: 20 puan
- Social performance bonus: 15 puan (kategori ortalamasının üzerinde)
- Competition level: 10 puan
"""
score = 0
# 1. Rating skoru (30 puan)
if rating >= 4.8:
score += 35
elif rating >= 4.6:
score += 30
elif rating >= 4.5:
score += 25
elif rating >= 4.0:
score += 15
else:
score += 0
# 2. Potansiyel skoru (25 puan)
score += (potential_score / 100) * 25
# 3. Conversion rate skoru (20 puan)
if conversion_rate >= 5:
score += 20
elif conversion_rate >= 3:
score += 15
elif conversion_rate >= 2:
score += 10
else:
score += 5
# 4. Social performance bonus (15 puan)
# Kategori ortalamasının ne kadar üzerinde?
views_multiplier = (page_views / threshold_views) if threshold_views > 0 else 0
baskets_multiplier = (baskets / threshold_baskets) if threshold_baskets > 0 else 0
avg_multiplier = (views_multiplier + baskets_multiplier) / 2
if avg_multiplier >= 3.0:
score += 15 # Kategori ortalamasının 3+ katı
elif avg_multiplier >= 2.5:
score += 12
elif avg_multiplier >= 2.0:
score += 10
else:
score += 5
# 5. Rekabet seviyesi skoru (10 puan)
if competition_level == "low":
score += 10
elif competition_level == "medium":
score += 7
else:
score += 3
return min(100, round(score, 2))