mirror of
https://github.com/nethunterzist/trendyol-analiz
synced 2026-07-01 01:17:04 +00:00
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>
374 lines
16 KiB
Python
374 lines
16 KiB
Python
"""
|
||
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))
|
||
|