Spaces:
Running
Running
Deploy Rankora API (clean bundle)
Browse files
app/routers/products.py
CHANGED
|
@@ -127,7 +127,7 @@ def get_product(
|
|
| 127 |
@router.get("/{asin}/buy-box/history")
|
| 128 |
def get_buy_box_history(
|
| 129 |
asin: str,
|
| 130 |
-
days: int = Query(90, ge=7, le=365, description="30 / 90 / 180 / 365 β
|
| 131 |
db: Session = Depends(get_db),
|
| 132 |
current_user: User = Depends(get_current_user),
|
| 133 |
):
|
|
@@ -136,7 +136,7 @@ def get_buy_box_history(
|
|
| 136 |
if not product:
|
| 137 |
raise HTTPException(status_code=404, detail="Product not found")
|
| 138 |
|
| 139 |
-
# Enough Rankora price points for
|
| 140 |
hist_limit = 2000 if days >= 180 else 500
|
| 141 |
latest = (
|
| 142 |
db.query(PriceHistory)
|
|
|
|
| 127 |
@router.get("/{asin}/buy-box/history")
|
| 128 |
def get_buy_box_history(
|
| 129 |
asin: str,
|
| 130 |
+
days: int = Query(90, ge=7, le=365, description="30 / 90 / 180 / 365 β Rankora scrape history"),
|
| 131 |
db: Session = Depends(get_db),
|
| 132 |
current_user: User = Depends(get_current_user),
|
| 133 |
):
|
|
|
|
| 136 |
if not product:
|
| 137 |
raise HTTPException(status_code=404, detail="Product not found")
|
| 138 |
|
| 139 |
+
# Enough Rankora price points for long-range charts
|
| 140 |
hist_limit = 2000 if days >= 180 else 500
|
| 141 |
latest = (
|
| 142 |
db.query(PriceHistory)
|
app/services/analytics/buy_box_history.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"""Persist and analyze Buy Box winner history over time.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
- Keepa Product API (optional backfill for 6β12 months when KEEPA_API_KEY is set)
|
| 6 |
"""
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -151,10 +150,10 @@ def merge_snapshots(
|
|
| 151 |
keepa_dicts: Optional[List[dict]] = None,
|
| 152 |
range_days: int = 365,
|
| 153 |
) -> List[SnapshotLike]:
|
| 154 |
-
"""Merge Keepa
|
| 155 |
|
| 156 |
-
|
| 157 |
-
Keepa
|
| 158 |
"""
|
| 159 |
cutoff = datetime.now(timezone.utc) - timedelta(days=max(range_days, 1))
|
| 160 |
local = [orm_to_snapshot_like(s) for s in rankora]
|
|
@@ -283,7 +282,7 @@ def rotation_from_snapshots(
|
|
| 283 |
"total_sellers": 0,
|
| 284 |
"source": "insufficient_data",
|
| 285 |
"snapshot_count": len(snapshots),
|
| 286 |
-
"disclaimer": "Refresh this product a few times to build Buy Box history
|
| 287 |
}
|
| 288 |
|
| 289 |
now = datetime.now(timezone.utc)
|
|
@@ -373,8 +372,9 @@ def rotation_from_snapshots(
|
|
| 373 |
"source": hist_source,
|
| 374 |
"snapshot_count": len(snapshots),
|
| 375 |
"disclaimer": (
|
| 376 |
-
f"Buy Box share from {len(snapshots)} snapshots over {range_days} days "
|
| 377 |
-
f"(source: {hist_source}).
|
|
|
|
| 378 |
),
|
| 379 |
}
|
| 380 |
|
|
@@ -524,16 +524,34 @@ def build_market_history_summary(
|
|
| 524 |
bb_prices = [float(s.price) for s in snapshots if s.price is not None]
|
| 525 |
hist_prices = [float(r["price"]) for r in price_history if r.get("price") is not None]
|
| 526 |
all_prices = bb_prices or hist_prices
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
return {
|
| 528 |
"range_days": range_days,
|
| 529 |
"buy_box_snapshots": len(snapshots),
|
| 530 |
"price_points": len(price_history),
|
|
|
|
|
|
|
| 531 |
"price_current": round(all_prices[-1], 2) if all_prices else None,
|
| 532 |
"price_avg": round(sum(all_prices) / len(all_prices), 2) if all_prices else None,
|
| 533 |
"price_min": round(min(all_prices), 2) if all_prices else None,
|
| 534 |
"price_max": round(max(all_prices), 2) if all_prices else None,
|
| 535 |
-
"first_snapshot":
|
| 536 |
-
"last_snapshot":
|
| 537 |
"first_price_record": price_history[0]["recorded_at"] if price_history else None,
|
| 538 |
"last_price_record": price_history[-1]["recorded_at"] if price_history else None,
|
| 539 |
"buy_box_price_min": round(min(bb_prices), 2) if bb_prices else None,
|
|
@@ -693,11 +711,11 @@ def build_historical_charts_payload(
|
|
| 693 |
"bsr_yearly": build_yearly_aggregates(price_history, "bsr"),
|
| 694 |
"range_options": [30, 90, 180, 365],
|
| 695 |
"how_it_works": [
|
| 696 |
-
"Rankora scrapes Amazon every ~6 hours and saves Buy Box winner + price
|
| 697 |
-
"
|
| 698 |
-
"
|
| 699 |
-
"Buy Box % = hours each seller held the box between snapshots (time-weighted).",
|
| 700 |
-
"
|
| 701 |
],
|
| 702 |
}
|
| 703 |
|
|
|
|
| 1 |
"""Persist and analyze Buy Box winner history over time.
|
| 2 |
|
| 3 |
+
Primary source: Rankora `buy_box_snapshots` + `price_history` (forward scrapes).
|
| 4 |
+
Keepa Product API remains optional/paid and dormant unless KEEPA_API_KEY is set.
|
|
|
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 150 |
keepa_dicts: Optional[List[dict]] = None,
|
| 151 |
range_days: int = 365,
|
| 152 |
) -> List[SnapshotLike]:
|
| 153 |
+
"""Merge optional Keepa rows + Rankora live snapshots.
|
| 154 |
|
| 155 |
+
Default FYP path: keepa_dicts empty β Rankora-only.
|
| 156 |
+
If Keepa is ever enabled: Rankora wins on overlapping days.
|
| 157 |
"""
|
| 158 |
cutoff = datetime.now(timezone.utc) - timedelta(days=max(range_days, 1))
|
| 159 |
local = [orm_to_snapshot_like(s) for s in rankora]
|
|
|
|
| 282 |
"total_sellers": 0,
|
| 283 |
"source": "insufficient_data",
|
| 284 |
"snapshot_count": len(snapshots),
|
| 285 |
+
"disclaimer": "Refresh this product a few times (or wait for the 6h tracker) to build Buy Box history from Rankora scrapes.",
|
| 286 |
}
|
| 287 |
|
| 288 |
now = datetime.now(timezone.utc)
|
|
|
|
| 372 |
"source": hist_source,
|
| 373 |
"snapshot_count": len(snapshots),
|
| 374 |
"disclaimer": (
|
| 375 |
+
f"Buy Box share from {len(snapshots)} Rankora snapshots over {range_days} days "
|
| 376 |
+
f"(source: {hist_source}). History starts when Rankora first tracked this ASIN β "
|
| 377 |
+
"keep Refreshing / tracking for fuller 180dβ365d charts."
|
| 378 |
),
|
| 379 |
}
|
| 380 |
|
|
|
|
| 524 |
bb_prices = [float(s.price) for s in snapshots if s.price is not None]
|
| 525 |
hist_prices = [float(r["price"]) for r in price_history if r.get("price") is not None]
|
| 526 |
all_prices = bb_prices or hist_prices
|
| 527 |
+
first_snap = _ensure_utc(snapshots[0].recorded_at) if snapshots else None
|
| 528 |
+
last_snap = _ensure_utc(snapshots[-1].recorded_at) if snapshots else None
|
| 529 |
+
span_days = 0
|
| 530 |
+
if first_snap and last_snap:
|
| 531 |
+
span_days = max(1, (last_snap - first_snap).days + 1)
|
| 532 |
+
elif price_history:
|
| 533 |
+
try:
|
| 534 |
+
first_p = datetime.fromisoformat(str(price_history[0]["recorded_at"]).replace("Z", "+00:00"))
|
| 535 |
+
last_p = datetime.fromisoformat(str(price_history[-1]["recorded_at"]).replace("Z", "+00:00"))
|
| 536 |
+
if first_p.tzinfo is None:
|
| 537 |
+
first_p = first_p.replace(tzinfo=timezone.utc)
|
| 538 |
+
if last_p.tzinfo is None:
|
| 539 |
+
last_p = last_p.replace(tzinfo=timezone.utc)
|
| 540 |
+
span_days = max(1, (last_p - first_p).days + 1)
|
| 541 |
+
except Exception:
|
| 542 |
+
span_days = 0
|
| 543 |
return {
|
| 544 |
"range_days": range_days,
|
| 545 |
"buy_box_snapshots": len(snapshots),
|
| 546 |
"price_points": len(price_history),
|
| 547 |
+
"history_span_days": span_days,
|
| 548 |
+
"partial_range": bool(span_days and span_days < range_days),
|
| 549 |
"price_current": round(all_prices[-1], 2) if all_prices else None,
|
| 550 |
"price_avg": round(sum(all_prices) / len(all_prices), 2) if all_prices else None,
|
| 551 |
"price_min": round(min(all_prices), 2) if all_prices else None,
|
| 552 |
"price_max": round(max(all_prices), 2) if all_prices else None,
|
| 553 |
+
"first_snapshot": first_snap.isoformat() if first_snap else None,
|
| 554 |
+
"last_snapshot": last_snap.isoformat() if last_snap else None,
|
| 555 |
"first_price_record": price_history[0]["recorded_at"] if price_history else None,
|
| 556 |
"last_price_record": price_history[-1]["recorded_at"] if price_history else None,
|
| 557 |
"buy_box_price_min": round(min(bb_prices), 2) if bb_prices else None,
|
|
|
|
| 711 |
"bsr_yearly": build_yearly_aggregates(price_history, "bsr"),
|
| 712 |
"range_options": [30, 90, 180, 365],
|
| 713 |
"how_it_works": [
|
| 714 |
+
"Rankora scrapes Amazon every ~6 hours (or on Refresh) and saves Buy Box winner + price.",
|
| 715 |
+
"History is forward-only: the timeline starts when Rankora first tracked this ASIN.",
|
| 716 |
+
"30d / 90d charts fill quickly; 180d / 365d grow as you keep the ASIN tracked.",
|
| 717 |
+
"Buy Box % = hours each seller held the box between Rankora snapshots (time-weighted).",
|
| 718 |
+
"No paid history API is required β Keepa remains optional/paid and unused when unset.",
|
| 719 |
],
|
| 720 |
}
|
| 721 |
|
app/services/external/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
"""
|
|
|
|
| 1 |
+
"""Optional third-party providers (Keepa is paid-only; Rankora scrapes are default)."""
|
app/services/external/keepa_client.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
"""Keepa Product API β
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
Docs: https://keepa.com/#!discuss/t/product-object/116
|
| 4 |
-
- Keepa time = minutes since 2011-01-01 UTC
|
| 5 |
-
- Prices in cents (β1 = out of stock / no offer)
|
| 6 |
-
- buybox=1 adds BUY_BOX_SHIPPING csv + buyBoxSellerIdHistory
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 1 |
+
"""Keepa Product API β optional PAID backfill (dormant without KEEPA_API_KEY).
|
| 2 |
+
|
| 3 |
+
Rankora FYP default is Rankora-only scrape history. Keepa has no free API tier.
|
| 4 |
+
This module is unused unless a paid key is configured.
|
| 5 |
|
| 6 |
Docs: https://keepa.com/#!discuss/t/product-object/116
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
app/services/product_service.py
CHANGED
|
@@ -378,7 +378,7 @@ def build_response(db: Session, product: Product, data_source: str = "cached") -
|
|
| 378 |
reviews = latest.review_count if latest else 0
|
| 379 |
|
| 380 |
bb_data = _buy_box_data_from_product(product)
|
| 381 |
-
#
|
| 382 |
snapshots = get_buy_box_snapshots(db, str(product.id), days=180, limit=2000)
|
| 383 |
rotation = rotation_from_snapshots(snapshots, range_days=90, fallback_data={**bb_data, "price": price})
|
| 384 |
history_timeline = build_buy_box_timeline(snapshots, range_days=90)
|
|
|
|
| 378 |
reviews = latest.review_count if latest else 0
|
| 379 |
|
| 380 |
bb_data = _buy_box_data_from_product(product)
|
| 381 |
+
# Rankora-only history for product page (longer ranges via /buy-box/history)
|
| 382 |
snapshots = get_buy_box_snapshots(db, str(product.id), days=180, limit=2000)
|
| 383 |
rotation = rotation_from_snapshots(snapshots, range_days=90, fallback_data={**bb_data, "price": price})
|
| 384 |
history_timeline = build_buy_box_timeline(snapshots, range_days=90)
|