mirror of
https://github.com/nethunterzist/trendyol-analiz
synced 2026-07-02 09:57:02 +00:00
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>
This commit is contained in:
@@ -10,6 +10,9 @@ import math
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime
|
||||
from logging_config import get_logger
|
||||
|
||||
log = get_logger("scraper")
|
||||
|
||||
|
||||
class TrendyolScraper:
|
||||
@@ -55,7 +58,7 @@ class TrendyolScraper:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Sayfa {page} error: {e}")
|
||||
log.warning(f"Sayfa {page} error: {e}")
|
||||
return None
|
||||
|
||||
def get_total_count(self) -> int:
|
||||
@@ -96,7 +99,7 @@ class TrendyolScraper:
|
||||
# Sayfa sayısını hesapla
|
||||
total_pages = self.calculate_total_pages(total_count, max_pages)
|
||||
|
||||
print(f"📦 Kategori {self.category_id}: {total_count} ürün, {total_pages} sayfa çekilecek")
|
||||
log.info(f"Kategori {self.category_id}: {total_count} ürün, {total_pages} sayfa çekilecek")
|
||||
|
||||
# Sayfaları çek
|
||||
all_products = []
|
||||
@@ -105,7 +108,7 @@ class TrendyolScraper:
|
||||
data = self.fetch_page(page)
|
||||
|
||||
if not data or not data.get('isSuccess'):
|
||||
print(f"⚠️ Sayfa {page} atlandı")
|
||||
log.warning(f"Sayfa {page} atlandı")
|
||||
continue
|
||||
|
||||
products = data.get('products', [])
|
||||
@@ -144,7 +147,7 @@ class TrendyolScraper:
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Dosya kaydetme hatası: {e}")
|
||||
log.error(f"Dosya kaydetme hatası: {e}")
|
||||
return False
|
||||
|
||||
def get_category_info(self) -> Optional[Dict[str, Any]]:
|
||||
@@ -157,6 +160,112 @@ class TrendyolScraper:
|
||||
return data.get('categoryInfo', {})
|
||||
|
||||
|
||||
class TrendyolSearchScraper:
|
||||
"""Trendyol Search API ile ürün çeker — tüm kategori tipleri için çalışır (-c ve -s)"""
|
||||
|
||||
API_BASE_URL = "https://apigw.trendyol.com/discovery-sfint-search-service/api/search/products"
|
||||
|
||||
def __init__(self, path_model: str, page_size: int = 24):
|
||||
self.path_model = path_model
|
||||
self.page_size = page_size
|
||||
self.headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
"Accept": "application/json",
|
||||
"Referer": f"https://www.trendyol.com/{path_model}",
|
||||
"Origin": "https://www.trendyol.com"
|
||||
}
|
||||
self.cookies = {
|
||||
"storefrontId": "1",
|
||||
"language": "tr",
|
||||
"countryCode": "TR"
|
||||
}
|
||||
|
||||
def fetch_page(self, page: int) -> Optional[Dict[str, Any]]:
|
||||
"""Tek sayfa çeker"""
|
||||
params = {
|
||||
"pathModel": self.path_model,
|
||||
"pi": page,
|
||||
"ps": self.page_size,
|
||||
"channelId": 1,
|
||||
"storefrontId": 1,
|
||||
"culture": "tr-TR"
|
||||
}
|
||||
try:
|
||||
response = requests.get(
|
||||
self.API_BASE_URL,
|
||||
params=params,
|
||||
headers=self.headers,
|
||||
cookies=self.cookies,
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.warning(f"Search API sayfa {page} error ({self.path_model}): {e}")
|
||||
return None
|
||||
|
||||
def fetch_all_products(self, delay: float = 1.0, max_pages: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Tüm ürünleri çeker, normalize eder (max_pages=10 x page_size=24 = 240 ürün)"""
|
||||
first = self.fetch_page(1)
|
||||
if not first:
|
||||
return []
|
||||
|
||||
total = first.get("total", 0) or first.get("totalCount", 0) or first.get("roughTotal", 0)
|
||||
raw_products = first.get("products", [])
|
||||
|
||||
if total == 0 and not raw_products:
|
||||
return []
|
||||
|
||||
# total 0 olsa bile ürün varsa en az 1 sayfa çek
|
||||
if total == 0 and raw_products:
|
||||
total = len(raw_products)
|
||||
|
||||
total_pages = min(math.ceil(total / self.page_size), max_pages)
|
||||
log.info(f"Search API {self.path_model}: {total} ürün, {total_pages} sayfa çekilecek")
|
||||
|
||||
for page in range(2, total_pages + 1):
|
||||
data = self.fetch_page(page)
|
||||
if data and data.get("products"):
|
||||
raw_products.extend(data["products"])
|
||||
if page < total_pages:
|
||||
time.sleep(delay)
|
||||
|
||||
return [_normalize_search_product(p) for p in raw_products]
|
||||
|
||||
|
||||
def _normalize_search_product(raw: dict) -> dict:
|
||||
"""Search API ürün formatını mevcut sisteme uyumlu hale getir"""
|
||||
brand = raw.get("brand", {})
|
||||
if isinstance(brand, str):
|
||||
brand = {"name": brand}
|
||||
|
||||
price = raw.get("price", {})
|
||||
if isinstance(price, (int, float)):
|
||||
price = {"sellingPrice": price, "originalPrice": price}
|
||||
elif isinstance(price, dict) and "sellingPrice" not in price:
|
||||
# Search API returns current/discountedPrice/originalPrice — map to sellingPrice
|
||||
price["sellingPrice"] = price.get("discountedPrice") or price.get("current") or price.get("originalPrice") or price.get("old") or 0
|
||||
|
||||
rating = raw.get("ratingScore", {})
|
||||
if rating is None:
|
||||
rating = {}
|
||||
|
||||
return {
|
||||
"id": raw.get("id") or raw.get("contentId"),
|
||||
"name": raw.get("name", ""),
|
||||
"brand": brand,
|
||||
"price": price,
|
||||
"ratingScore": rating,
|
||||
"url": raw.get("url", ""),
|
||||
"imageUrl": raw.get("image", raw.get("imageUrl", "")),
|
||||
"merchantListings": raw.get("merchantListings", []),
|
||||
"winnerVariant": raw.get("winnerVariant", {}),
|
||||
"socialProofs": raw.get("socialProofs", []),
|
||||
"categoryId": raw.get("categoryId"),
|
||||
"categoryName": raw.get("categoryName"),
|
||||
}
|
||||
|
||||
|
||||
def scrape_category(category_id: int, category_name: str, output_dir: str = "../categories") -> Dict[str, Any]:
|
||||
"""
|
||||
Tek bir kategoriyi çeker
|
||||
@@ -227,9 +336,7 @@ def scrape_multiple_categories(categories: List[tuple], delay: float = 2.0) -> D
|
||||
}
|
||||
|
||||
for i, (cat_id, cat_name) in enumerate(categories, 1):
|
||||
print(f"\n{'='*80}")
|
||||
print(f"📂 [{i}/{len(categories)}] {cat_name} (ID: {cat_id})")
|
||||
print('='*80)
|
||||
log.info(f"[{i}/{len(categories)}] {cat_name} (ID: {cat_id})")
|
||||
|
||||
result = scrape_category(cat_id, cat_name)
|
||||
results["details"].append(result)
|
||||
@@ -237,10 +344,10 @@ def scrape_multiple_categories(categories: List[tuple], delay: float = 2.0) -> D
|
||||
if result["success"]:
|
||||
results["successful"] += 1
|
||||
results["total_products"] += result["total_products"]
|
||||
print(f"✅ Başarılı: {result['total_products']} ürün")
|
||||
log.info(f"Başarılı: {result['total_products']} ürün")
|
||||
else:
|
||||
results["failed"] += 1
|
||||
print(f"❌ Hata: {result['error']}")
|
||||
log.error(f"Hata: {result['error']}")
|
||||
|
||||
# Kategoriler arası bekleme
|
||||
if i < len(categories):
|
||||
|
||||
Reference in New Issue
Block a user