Spaces:
Running
Running
Deploy Rankora API: buy box competitors, offers persistence, scraper fixes
Browse files- README.md +13 -13
- app/database.py +78 -74
- app/dependencies.py +51 -51
- app/models/__init__.py +2 -2
- app/models/product.py +66 -64
- app/models/tracking.py +17 -17
- app/models/user.py +21 -21
- app/routers/advanced_router.py +152 -152
- app/routers/analysis.py +103 -103
- app/routers/auth.py +97 -97
- app/routers/competitors.py +45 -45
- app/routers/keywords.py +30 -30
- app/routers/ml_router.py +276 -276
- app/routers/products.py +233 -233
- app/routers/profit.py +176 -176
- app/schemas/user.py +31 -31
- app/security/__init__.py +1 -1
- app/security/input_guard.py +83 -83
- app/security/middleware.py +67 -67
- app/security/rate_limit.py +70 -70
- app/security/secrets.py +44 -44
- app/security/token_revocation.py +38 -38
- app/services/amazon/competitor_service.py +137 -137
- app/services/amazon/keyword_service.py +131 -131
- app/services/amazon/offers_scraper.py +204 -158
- app/services/amazon/product_scraper.py +416 -416
- app/services/amazon/sales_estimator.py +73 -73
- app/services/amazon/scraper_utils.py +55 -55
- app/services/analytics/advanced_services.py +359 -359
- app/services/analytics/ai_analyzer.py +188 -188
- app/services/analytics/amazon_fees.py +227 -227
- app/services/analytics/buy_box_history.py +563 -563
- app/services/analytics/buy_box_rotation.py +153 -153
- app/services/analytics/profit_calculator.py +360 -360
- app/services/analytics/tracking_service.py +72 -72
- app/services/llm_service.py +198 -198
- app/services/ml/demand_forecaster.py +232 -232
- app/services/ml/fake_review_detector.py +286 -286
- app/services/ml/ml_engine.py +509 -509
- app/services/ml/niche_scorer.py +282 -282
- app/services/ml/price_predictor.py +239 -239
- app/services/ml/sklearn_engine.py +312 -312
- app/services/product_service.py +504 -418
- app/services/scheduler.py +42 -42
- app/services/supplier_service.py +153 -153
- app/utils/validators.py +11 -11
- requirements.txt +19 -19
README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Rankora API
|
| 3 |
-
emoji: 📊
|
| 4 |
-
colorFrom: red
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
-
pinned: false
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
# Rankora Backend API
|
| 12 |
-
|
| 13 |
-
FastAPI backend for Rankora Amazon intelligence platform.
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Rankora API
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Rankora Backend API
|
| 12 |
+
|
| 13 |
+
FastAPI backend for Rankora Amazon intelligence platform.
|
app/database.py
CHANGED
|
@@ -1,74 +1,78 @@
|
|
| 1 |
-
from sqlalchemy import create_engine, text
|
| 2 |
-
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
-
from sqlalchemy.orm import sessionmaker
|
| 4 |
-
import os
|
| 5 |
-
|
| 6 |
-
from app.config import settings
|
| 7 |
-
|
| 8 |
-
DATABASE_URL = settings.database_url or os.getenv("DATABASE_URL", "sqlite:///./amazon_intel.db")
|
| 9 |
-
|
| 10 |
-
if DATABASE_URL.startswith("sqlite"):
|
| 11 |
-
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
| 12 |
-
else:
|
| 13 |
-
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=300)
|
| 14 |
-
|
| 15 |
-
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 16 |
-
Base = declarative_base()
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def migrate_schema():
|
| 20 |
-
"""Add columns when schema evolves (SQLite + PostgreSQL)."""
|
| 21 |
-
user_alters = [
|
| 22 |
-
"ALTER TABLE users ADD COLUMN token_version INTEGER DEFAULT 0",
|
| 23 |
-
]
|
| 24 |
-
sqlite_product_alters = [
|
| 25 |
-
"ALTER TABLE products ADD COLUMN seller_count INTEGER",
|
| 26 |
-
"ALTER TABLE products ADD COLUMN buy_box_winner VARCHAR(255)",
|
| 27 |
-
"ALTER TABLE products ADD COLUMN buy_box_price NUMERIC(10,2)",
|
| 28 |
-
"ALTER TABLE products ADD COLUMN buy_box_is_fba BOOLEAN DEFAULT 1",
|
| 29 |
-
"ALTER TABLE products ADD COLUMN is_amazon_sold BOOLEAN DEFAULT 0",
|
| 30 |
-
"ALTER TABLE products ADD COLUMN last_data_source VARCHAR(20) DEFAULT 'cached'",
|
| 31 |
-
"ALTER TABLE products ADD COLUMN upc VARCHAR(20)",
|
| 32 |
-
"ALTER TABLE products ADD COLUMN has_buy_box BOOLEAN DEFAULT 1",
|
| 33 |
-
"ALTER TABLE products ADD COLUMN package_weight_lbs NUMERIC(8,3)",
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
"ALTER TABLE products ADD COLUMN IF NOT EXISTS
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
conn.execute(text(
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine, text
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
from app.config import settings
|
| 7 |
+
|
| 8 |
+
DATABASE_URL = settings.database_url or os.getenv("DATABASE_URL", "sqlite:///./amazon_intel.db")
|
| 9 |
+
|
| 10 |
+
if DATABASE_URL.startswith("sqlite"):
|
| 11 |
+
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
| 12 |
+
else:
|
| 13 |
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=300)
|
| 14 |
+
|
| 15 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 16 |
+
Base = declarative_base()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def migrate_schema():
|
| 20 |
+
"""Add columns when schema evolves (SQLite + PostgreSQL)."""
|
| 21 |
+
user_alters = [
|
| 22 |
+
"ALTER TABLE users ADD COLUMN token_version INTEGER DEFAULT 0",
|
| 23 |
+
]
|
| 24 |
+
sqlite_product_alters = [
|
| 25 |
+
"ALTER TABLE products ADD COLUMN seller_count INTEGER",
|
| 26 |
+
"ALTER TABLE products ADD COLUMN buy_box_winner VARCHAR(255)",
|
| 27 |
+
"ALTER TABLE products ADD COLUMN buy_box_price NUMERIC(10,2)",
|
| 28 |
+
"ALTER TABLE products ADD COLUMN buy_box_is_fba BOOLEAN DEFAULT 1",
|
| 29 |
+
"ALTER TABLE products ADD COLUMN is_amazon_sold BOOLEAN DEFAULT 0",
|
| 30 |
+
"ALTER TABLE products ADD COLUMN last_data_source VARCHAR(20) DEFAULT 'cached'",
|
| 31 |
+
"ALTER TABLE products ADD COLUMN upc VARCHAR(20)",
|
| 32 |
+
"ALTER TABLE products ADD COLUMN has_buy_box BOOLEAN DEFAULT 1",
|
| 33 |
+
"ALTER TABLE products ADD COLUMN package_weight_lbs NUMERIC(8,3)",
|
| 34 |
+
"ALTER TABLE products ADD COLUMN other_sellers_json TEXT",
|
| 35 |
+
"ALTER TABLE products ADD COLUMN offers_source VARCHAR(30)",
|
| 36 |
+
]
|
| 37 |
+
pg_product_alters = [
|
| 38 |
+
"ALTER TABLE products ADD COLUMN IF NOT EXISTS upc VARCHAR(20)",
|
| 39 |
+
"ALTER TABLE products ADD COLUMN IF NOT EXISTS has_buy_box BOOLEAN DEFAULT TRUE",
|
| 40 |
+
"ALTER TABLE products ADD COLUMN IF NOT EXISTS package_weight_lbs NUMERIC(8,3)",
|
| 41 |
+
"ALTER TABLE products ADD COLUMN IF NOT EXISTS other_sellers_json TEXT",
|
| 42 |
+
"ALTER TABLE products ADD COLUMN IF NOT EXISTS offers_source VARCHAR(30)",
|
| 43 |
+
]
|
| 44 |
+
is_pg = DATABASE_URL.startswith("postgresql")
|
| 45 |
+
with engine.connect() as conn:
|
| 46 |
+
for sql in user_alters:
|
| 47 |
+
try:
|
| 48 |
+
if is_pg:
|
| 49 |
+
conn.execute(text(
|
| 50 |
+
"ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER DEFAULT 0"
|
| 51 |
+
))
|
| 52 |
+
else:
|
| 53 |
+
conn.execute(text(sql))
|
| 54 |
+
conn.commit()
|
| 55 |
+
except Exception:
|
| 56 |
+
pass
|
| 57 |
+
if DATABASE_URL.startswith("sqlite"):
|
| 58 |
+
for sql in sqlite_product_alters:
|
| 59 |
+
try:
|
| 60 |
+
conn.execute(text(sql))
|
| 61 |
+
conn.commit()
|
| 62 |
+
except Exception:
|
| 63 |
+
pass
|
| 64 |
+
if is_pg:
|
| 65 |
+
for sql in pg_product_alters:
|
| 66 |
+
try:
|
| 67 |
+
conn.execute(text(sql))
|
| 68 |
+
conn.commit()
|
| 69 |
+
except Exception:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def get_db():
|
| 74 |
+
db = SessionLocal()
|
| 75 |
+
try:
|
| 76 |
+
yield db
|
| 77 |
+
finally:
|
| 78 |
+
db.close()
|
app/dependencies.py
CHANGED
|
@@ -1,51 +1,51 @@
|
|
| 1 |
-
from fastapi import Depends, HTTPException
|
| 2 |
-
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
-
from jose import jwt, JWTError
|
| 4 |
-
from sqlalchemy.orm import Session
|
| 5 |
-
|
| 6 |
-
from app.database import get_db
|
| 7 |
-
from app.models.user import User
|
| 8 |
-
from app.config import settings
|
| 9 |
-
from app.security.token_revocation import is_jti_revoked
|
| 10 |
-
|
| 11 |
-
security = HTTPBearer()
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
def get_current_user(
|
| 15 |
-
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 16 |
-
db: Session = Depends(get_db),
|
| 17 |
-
) -> User:
|
| 18 |
-
token = credentials.credentials
|
| 19 |
-
try:
|
| 20 |
-
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
| 21 |
-
user_id: str = payload.get("sub")
|
| 22 |
-
jti: str | None = payload.get("jti")
|
| 23 |
-
token_version: int = int(payload.get("tv") or 0)
|
| 24 |
-
if not user_id:
|
| 25 |
-
raise HTTPException(status_code=401, detail="Invalid token")
|
| 26 |
-
if is_jti_revoked(jti):
|
| 27 |
-
raise HTTPException(status_code=401, detail="Token revoked")
|
| 28 |
-
except JWTError:
|
| 29 |
-
raise HTTPException(status_code=401, detail="Invalid token")
|
| 30 |
-
|
| 31 |
-
user = db.query(User).filter(User.id == user_id).first()
|
| 32 |
-
if not user:
|
| 33 |
-
raise HTTPException(status_code=401, detail="User not found")
|
| 34 |
-
if not user.is_active:
|
| 35 |
-
raise HTTPException(status_code=403, detail="Account deactivated")
|
| 36 |
-
if token_version != (user.token_version or 0):
|
| 37 |
-
raise HTTPException(status_code=401, detail="Token expired — please log in again")
|
| 38 |
-
return user
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def enforce_search_limit(user: User, db: Session) -> None:
|
| 42 |
-
"""Optional per-plan monthly cap. Off by default until plans/billing ship."""
|
| 43 |
-
if not settings.enforce_search_limits:
|
| 44 |
-
return
|
| 45 |
-
if user.monthly_searches >= user.search_limit:
|
| 46 |
-
raise HTTPException(
|
| 47 |
-
status_code=429,
|
| 48 |
-
detail=f"Monthly search limit reached ({user.search_limit}). Upgrade your plan or wait until next month.",
|
| 49 |
-
)
|
| 50 |
-
user.monthly_searches += 1
|
| 51 |
-
db.commit()
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from jose import jwt, JWTError
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from app.database import get_db
|
| 7 |
+
from app.models.user import User
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from app.security.token_revocation import is_jti_revoked
|
| 10 |
+
|
| 11 |
+
security = HTTPBearer()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_current_user(
|
| 15 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 16 |
+
db: Session = Depends(get_db),
|
| 17 |
+
) -> User:
|
| 18 |
+
token = credentials.credentials
|
| 19 |
+
try:
|
| 20 |
+
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
| 21 |
+
user_id: str = payload.get("sub")
|
| 22 |
+
jti: str | None = payload.get("jti")
|
| 23 |
+
token_version: int = int(payload.get("tv") or 0)
|
| 24 |
+
if not user_id:
|
| 25 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 26 |
+
if is_jti_revoked(jti):
|
| 27 |
+
raise HTTPException(status_code=401, detail="Token revoked")
|
| 28 |
+
except JWTError:
|
| 29 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 30 |
+
|
| 31 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 32 |
+
if not user:
|
| 33 |
+
raise HTTPException(status_code=401, detail="User not found")
|
| 34 |
+
if not user.is_active:
|
| 35 |
+
raise HTTPException(status_code=403, detail="Account deactivated")
|
| 36 |
+
if token_version != (user.token_version or 0):
|
| 37 |
+
raise HTTPException(status_code=401, detail="Token expired — please log in again")
|
| 38 |
+
return user
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def enforce_search_limit(user: User, db: Session) -> None:
|
| 42 |
+
"""Optional per-plan monthly cap. Off by default until plans/billing ship."""
|
| 43 |
+
if not settings.enforce_search_limits:
|
| 44 |
+
return
|
| 45 |
+
if user.monthly_searches >= user.search_limit:
|
| 46 |
+
raise HTTPException(
|
| 47 |
+
status_code=429,
|
| 48 |
+
detail=f"Monthly search limit reached ({user.search_limit}). Upgrade your plan or wait until next month.",
|
| 49 |
+
)
|
| 50 |
+
user.monthly_searches += 1
|
| 51 |
+
db.commit()
|
app/models/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
-
from app.models.user import User
|
| 2 |
-
from app.models.product import Product, PriceHistory, TrackedProduct, BuyBoxSnapshot
|
| 3 |
from app.models.tracking import PriceAlert
|
|
|
|
| 1 |
+
from app.models.user import User
|
| 2 |
+
from app.models.product import Product, PriceHistory, TrackedProduct, BuyBoxSnapshot
|
| 3 |
from app.models.tracking import PriceAlert
|
app/models/product.py
CHANGED
|
@@ -1,65 +1,67 @@
|
|
| 1 |
-
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Numeric, Text
|
| 2 |
-
from sqlalchemy.sql import func
|
| 3 |
-
from app.database import Base
|
| 4 |
-
import uuid
|
| 5 |
-
|
| 6 |
-
def gen_uuid():
|
| 7 |
-
return str(uuid.uuid4())
|
| 8 |
-
|
| 9 |
-
class Product(Base):
|
| 10 |
-
__tablename__ = "products"
|
| 11 |
-
__table_args__ = {"extend_existing": True}
|
| 12 |
-
id = Column(String(36), primary_key=True, default=gen_uuid)
|
| 13 |
-
asin = Column(String(10), unique=True, nullable=False)
|
| 14 |
-
title = Column(Text, nullable=True)
|
| 15 |
-
brand = Column(String(255), nullable=True)
|
| 16 |
-
upc = Column(String(20), nullable=True)
|
| 17 |
-
category = Column(String(255), nullable=True)
|
| 18 |
-
image_url = Column(Text, nullable=True)
|
| 19 |
-
amazon_url = Column(Text, nullable=True)
|
| 20 |
-
is_prime = Column(Boolean, default=True)
|
| 21 |
-
seller_count = Column(Integer, nullable=True)
|
| 22 |
-
buy_box_winner = Column(String(255), nullable=True)
|
| 23 |
-
buy_box_price = Column(Numeric(10, 2), nullable=True)
|
| 24 |
-
buy_box_is_fba = Column(Boolean, default=True)
|
| 25 |
-
has_buy_box = Column(Boolean, default=True)
|
| 26 |
-
is_amazon_sold = Column(Boolean, default=False)
|
| 27 |
-
package_weight_lbs = Column(Numeric(8, 3), nullable=True)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
recorded_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Numeric, Text
|
| 2 |
+
from sqlalchemy.sql import func
|
| 3 |
+
from app.database import Base
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
def gen_uuid():
|
| 7 |
+
return str(uuid.uuid4())
|
| 8 |
+
|
| 9 |
+
class Product(Base):
|
| 10 |
+
__tablename__ = "products"
|
| 11 |
+
__table_args__ = {"extend_existing": True}
|
| 12 |
+
id = Column(String(36), primary_key=True, default=gen_uuid)
|
| 13 |
+
asin = Column(String(10), unique=True, nullable=False)
|
| 14 |
+
title = Column(Text, nullable=True)
|
| 15 |
+
brand = Column(String(255), nullable=True)
|
| 16 |
+
upc = Column(String(20), nullable=True)
|
| 17 |
+
category = Column(String(255), nullable=True)
|
| 18 |
+
image_url = Column(Text, nullable=True)
|
| 19 |
+
amazon_url = Column(Text, nullable=True)
|
| 20 |
+
is_prime = Column(Boolean, default=True)
|
| 21 |
+
seller_count = Column(Integer, nullable=True)
|
| 22 |
+
buy_box_winner = Column(String(255), nullable=True)
|
| 23 |
+
buy_box_price = Column(Numeric(10, 2), nullable=True)
|
| 24 |
+
buy_box_is_fba = Column(Boolean, default=True)
|
| 25 |
+
has_buy_box = Column(Boolean, default=True)
|
| 26 |
+
is_amazon_sold = Column(Boolean, default=False)
|
| 27 |
+
package_weight_lbs = Column(Numeric(8, 3), nullable=True)
|
| 28 |
+
other_sellers_json = Column(Text, nullable=True)
|
| 29 |
+
offers_source = Column(String(30), nullable=True)
|
| 30 |
+
last_data_source = Column(String(20), default="cached")
|
| 31 |
+
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
| 32 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 33 |
+
|
| 34 |
+
class PriceHistory(Base):
|
| 35 |
+
__tablename__ = "price_history"
|
| 36 |
+
__table_args__ = {"extend_existing": True}
|
| 37 |
+
id = Column(String(36), primary_key=True, default=gen_uuid)
|
| 38 |
+
product_id = Column(String(36), nullable=False)
|
| 39 |
+
price = Column(Numeric(10, 2), nullable=True)
|
| 40 |
+
bsr = Column(Integer, nullable=True)
|
| 41 |
+
rating = Column(Numeric(3, 2), nullable=True)
|
| 42 |
+
review_count = Column(Integer, nullable=True)
|
| 43 |
+
in_stock = Column(Boolean, default=True)
|
| 44 |
+
recorded_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 45 |
+
|
| 46 |
+
class TrackedProduct(Base):
|
| 47 |
+
__tablename__ = "tracked_products"
|
| 48 |
+
__table_args__ = {"extend_existing": True}
|
| 49 |
+
id = Column(String(36), primary_key=True, default=gen_uuid)
|
| 50 |
+
user_id = Column(String(36), nullable=False)
|
| 51 |
+
product_id = Column(String(36), nullable=False)
|
| 52 |
+
tracked_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class BuyBoxSnapshot(Base):
|
| 56 |
+
"""Point-in-time Buy Box winner captured on each product refresh."""
|
| 57 |
+
__tablename__ = "buy_box_snapshots"
|
| 58 |
+
__table_args__ = {"extend_existing": True}
|
| 59 |
+
id = Column(String(36), primary_key=True, default=gen_uuid)
|
| 60 |
+
product_id = Column(String(36), nullable=False, index=True)
|
| 61 |
+
winner = Column(String(255), nullable=True)
|
| 62 |
+
price = Column(Numeric(10, 2), nullable=True)
|
| 63 |
+
is_fba = Column(Boolean, nullable=True)
|
| 64 |
+
is_amazon = Column(Boolean, default=False)
|
| 65 |
+
seller_count = Column(Integer, nullable=True)
|
| 66 |
+
has_buy_box = Column(Boolean, default=True)
|
| 67 |
recorded_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
app/models/tracking.py
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
-
from sqlalchemy import Column, String, Float, Integer, DateTime, Boolean
|
| 2 |
-
from app.database import Base
|
| 3 |
-
from datetime import datetime, timezone
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class PriceAlert(Base):
|
| 7 |
-
"""Price alerts — separate from PriceHistory in product.py"""
|
| 8 |
-
__tablename__ = "price_alerts"
|
| 9 |
-
__table_args__ = {"extend_existing": True}
|
| 10 |
-
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 11 |
-
user_id = Column(String, index=True)
|
| 12 |
-
asin = Column(String, index=True)
|
| 13 |
-
alert_type = Column(String)
|
| 14 |
-
threshold = Column(Float)
|
| 15 |
-
is_active = Column(Boolean, default=True)
|
| 16 |
-
triggered = Column(Boolean, default=False)
|
| 17 |
-
triggered_at = Column(DateTime, nullable=True)
|
| 18 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Float, Integer, DateTime, Boolean
|
| 2 |
+
from app.database import Base
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PriceAlert(Base):
|
| 7 |
+
"""Price alerts — separate from PriceHistory in product.py"""
|
| 8 |
+
__tablename__ = "price_alerts"
|
| 9 |
+
__table_args__ = {"extend_existing": True}
|
| 10 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 11 |
+
user_id = Column(String, index=True)
|
| 12 |
+
asin = Column(String, index=True)
|
| 13 |
+
alert_type = Column(String)
|
| 14 |
+
threshold = Column(Float)
|
| 15 |
+
is_active = Column(Boolean, default=True)
|
| 16 |
+
triggered = Column(Boolean, default=False)
|
| 17 |
+
triggered_at = Column(DateTime, nullable=True)
|
| 18 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
app/models/user.py
CHANGED
|
@@ -1,22 +1,22 @@
|
|
| 1 |
-
from sqlalchemy import Column, String, Boolean, Integer, DateTime
|
| 2 |
-
from sqlalchemy.sql import func
|
| 3 |
-
from sqlalchemy.dialects.postgresql import UUID
|
| 4 |
-
from sqlalchemy import text
|
| 5 |
-
from app.database import Base
|
| 6 |
-
import uuid
|
| 7 |
-
|
| 8 |
-
class User(Base):
|
| 9 |
-
__tablename__ = "users"
|
| 10 |
-
|
| 11 |
-
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 12 |
-
email = Column(String(255), unique=True, nullable=False)
|
| 13 |
-
password_hash = Column(String(255), nullable=False)
|
| 14 |
-
full_name = Column(String(255), nullable=True)
|
| 15 |
-
plan = Column(String(20), default="free")
|
| 16 |
-
monthly_searches = Column(Integer, default=0)
|
| 17 |
-
search_limit = Column(Integer, default=50)
|
| 18 |
-
is_active = Column(Boolean, default=True)
|
| 19 |
-
is_verified = Column(Boolean, default=False)
|
| 20 |
-
token_version = Column(Integer, default=0)
|
| 21 |
-
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 22 |
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
|
|
| 1 |
+
from sqlalchemy import Column, String, Boolean, Integer, DateTime
|
| 2 |
+
from sqlalchemy.sql import func
|
| 3 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 4 |
+
from sqlalchemy import text
|
| 5 |
+
from app.database import Base
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
class User(Base):
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
|
| 11 |
+
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 12 |
+
email = Column(String(255), unique=True, nullable=False)
|
| 13 |
+
password_hash = Column(String(255), nullable=False)
|
| 14 |
+
full_name = Column(String(255), nullable=True)
|
| 15 |
+
plan = Column(String(20), default="free")
|
| 16 |
+
monthly_searches = Column(Integer, default=0)
|
| 17 |
+
search_limit = Column(Integer, default=50)
|
| 18 |
+
is_active = Column(Boolean, default=True)
|
| 19 |
+
is_verified = Column(Boolean, default=False)
|
| 20 |
+
token_version = Column(Integer, default=0)
|
| 21 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 22 |
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
app/routers/advanced_router.py
CHANGED
|
@@ -1,152 +1,152 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
-
from sqlalchemy.orm import Session
|
| 3 |
-
from pydantic import BaseModel, Field
|
| 4 |
-
from typing import Optional, Literal
|
| 5 |
-
|
| 6 |
-
from app.database import get_db
|
| 7 |
-
from app.dependencies import get_current_user, enforce_search_limit
|
| 8 |
-
from app.models.user import User
|
| 9 |
-
from app.models.product import Product, PriceHistory, TrackedProduct
|
| 10 |
-
from app.services.analytics.tracking_service import (
|
| 11 |
-
record_price, get_price_history, create_alert, get_alerts, check_alerts
|
| 12 |
-
)
|
| 13 |
-
from app.services.analytics.advanced_services import (
|
| 14 |
-
estimate_sales_v2, analyze_reviews, analyze_niche,
|
| 15 |
-
generate_keywords, analyze_buy_box
|
| 16 |
-
)
|
| 17 |
-
from app.security.input_guard import scan_user_text
|
| 18 |
-
from app.utils.validators import normalize_asin
|
| 19 |
-
|
| 20 |
-
router = APIRouter(prefix="/api/v2", tags=["Advanced Features"])
|
| 21 |
-
|
| 22 |
-
ALLOWED_ALERT_TYPES = {"price_drop", "price_increase", "bsr_change", "review_spike"}
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
class AlertCreate(BaseModel):
|
| 26 |
-
asin: str = Field(max_length=10)
|
| 27 |
-
alert_type: str = Field(max_length=32)
|
| 28 |
-
threshold: float = Field(ge=0, le=1_000_000)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class TrackRequest(BaseModel):
|
| 32 |
-
asin: str = Field(max_length=10)
|
| 33 |
-
title: Optional[str] = Field(default=None, max_length=500)
|
| 34 |
-
price: Optional[float] = None
|
| 35 |
-
bsr: Optional[int] = None
|
| 36 |
-
reviews: Optional[int] = None
|
| 37 |
-
rating: Optional[float] = None
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
class NicheRequest(BaseModel):
|
| 41 |
-
keyword: str = Field(max_length=120)
|
| 42 |
-
avg_bsr: float = Field(default=50000, ge=1, le=50_000_000)
|
| 43 |
-
avg_reviews: float = Field(default=200, ge=0, le=10_000_000)
|
| 44 |
-
avg_price: float = Field(default=25.0, ge=0, le=100_000)
|
| 45 |
-
product_count: int = Field(default=100, ge=1, le=100_000)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
class SalesRequest(BaseModel):
|
| 49 |
-
bsr: int = Field(ge=1, le=50_000_000)
|
| 50 |
-
category: str = Field(default="default", max_length=120)
|
| 51 |
-
price: float = Field(default=25.0, ge=0, le=100_000)
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
class ReviewRequest(BaseModel):
|
| 55 |
-
rating: float = Field(ge=0, le=5)
|
| 56 |
-
review_count: int = Field(ge=0, le=50_000_000)
|
| 57 |
-
title: str = Field(default="", max_length=500)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
class BuyBoxRequest(BaseModel):
|
| 61 |
-
price: float = Field(ge=0, le=100_000)
|
| 62 |
-
rating: float = Field(default=4.5, ge=0, le=5)
|
| 63 |
-
review_count: int = Field(default=100, ge=0, le=50_000_000)
|
| 64 |
-
is_fba: bool = True
|
| 65 |
-
is_prime: bool = True
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
class KeywordRequest(BaseModel):
|
| 69 |
-
seed_keyword: str = Field(max_length=120)
|
| 70 |
-
category: str = Field(default="", max_length=120)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
@router.get("/track/{asin}/history")
|
| 74 |
-
def price_history(asin: str, days: int = 30, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 75 |
-
asin = normalize_asin(asin)
|
| 76 |
-
days = min(max(days, 1), 365)
|
| 77 |
-
history = get_price_history(db, asin, days)
|
| 78 |
-
return {"asin": asin, "days": days, "history": history, "count": len(history)}
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
@router.get("/track/list")
|
| 82 |
-
def tracked_list(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 83 |
-
tracked = db.query(TrackedProduct).filter(TrackedProduct.user_id == str(current_user.id)).all()
|
| 84 |
-
result = []
|
| 85 |
-
for t in tracked:
|
| 86 |
-
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 87 |
-
if product:
|
| 88 |
-
latest = db.query(PriceHistory).filter(PriceHistory.product_id == product.id).order_by(PriceHistory.recorded_at.desc()).first()
|
| 89 |
-
result.append({
|
| 90 |
-
"asin": product.asin,
|
| 91 |
-
"title": product.title,
|
| 92 |
-
"tracked_since": t.tracked_at.isoformat() if t.tracked_at else None,
|
| 93 |
-
"price": float(latest.price) if latest and latest.price else None,
|
| 94 |
-
"bsr": latest.bsr if latest else None,
|
| 95 |
-
})
|
| 96 |
-
return {"products": result}
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
@router.post("/alerts")
|
| 100 |
-
def create_alert_endpoint(req: AlertCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 101 |
-
asin = normalize_asin(req.asin)
|
| 102 |
-
alert_type = req.alert_type.strip().lower()
|
| 103 |
-
if alert_type not in ALLOWED_ALERT_TYPES:
|
| 104 |
-
raise HTTPException(status_code=400, detail="Invalid alert type")
|
| 105 |
-
alert = create_alert(db, str(current_user.id), asin, alert_type, req.threshold)
|
| 106 |
-
return {"created": True, "alert_id": alert.id, "type": alert_type, "threshold": req.threshold}
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
@router.get("/alerts")
|
| 110 |
-
def list_alerts(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 111 |
-
alerts = get_alerts(db, str(current_user.id))
|
| 112 |
-
return {"alerts": [{"id": a.id, "asin": a.asin, "type": a.alert_type, "threshold": a.threshold, "triggered": a.triggered} for a in alerts]}
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
@router.post("/sales-estimate")
|
| 116 |
-
def sales_estimate(req: SalesRequest, current_user: User = Depends(get_current_user)):
|
| 117 |
-
category = scan_user_text(req.category, "category") if req.category != "default" else "default"
|
| 118 |
-
return estimate_sales_v2(req.bsr, category, req.price)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
@router.post("/review-analysis")
|
| 122 |
-
def review_analysis(req: ReviewRequest, current_user: User = Depends(get_current_user)):
|
| 123 |
-
title = scan_user_text(req.title, "title") if req.title else ""
|
| 124 |
-
return analyze_reviews(req.rating, req.review_count, title)
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
@router.post("/buy-box")
|
| 128 |
-
def buy_box(req: BuyBoxRequest, current_user: User = Depends(get_current_user)):
|
| 129 |
-
return analyze_buy_box(req.price, req.rating, req.review_count, req.is_fba, req.is_prime)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
@router.post("/niche")
|
| 133 |
-
def niche_finder(req: NicheRequest, current_user: User = Depends(get_current_user)):
|
| 134 |
-
keyword = scan_user_text(req.keyword, "keyword")
|
| 135 |
-
return analyze_niche(keyword, req.avg_bsr, req.avg_reviews, req.avg_price, req.product_count)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
@router.post("/keywords")
|
| 139 |
-
def keyword_research(req: KeywordRequest, current_user: User = Depends(get_current_user)):
|
| 140 |
-
seed = scan_user_text(req.seed_keyword, "seed_keyword")
|
| 141 |
-
category = scan_user_text(req.category, "category") if req.category else ""
|
| 142 |
-
return generate_keywords(seed, category)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
@router.get("/full-analysis/{asin}")
|
| 146 |
-
def full_analysis(asin: str, price: float = 25.0, bsr: int = 50000, reviews: int = 200, rating: float = 4.0, category: str = "default", db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 147 |
-
asin = normalize_asin(asin)
|
| 148 |
-
sales = estimate_sales_v2(bsr, category, price)
|
| 149 |
-
review_a = analyze_reviews(rating, reviews)
|
| 150 |
-
buy_box_a = analyze_buy_box(price, rating, reviews)
|
| 151 |
-
history = get_price_history(db, asin, 30)
|
| 152 |
-
return {"asin": asin, "sales_estimate": sales, "review_analysis": review_a, "buy_box": buy_box_a, "price_history": history}
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from typing import Optional, Literal
|
| 5 |
+
|
| 6 |
+
from app.database import get_db
|
| 7 |
+
from app.dependencies import get_current_user, enforce_search_limit
|
| 8 |
+
from app.models.user import User
|
| 9 |
+
from app.models.product import Product, PriceHistory, TrackedProduct
|
| 10 |
+
from app.services.analytics.tracking_service import (
|
| 11 |
+
record_price, get_price_history, create_alert, get_alerts, check_alerts
|
| 12 |
+
)
|
| 13 |
+
from app.services.analytics.advanced_services import (
|
| 14 |
+
estimate_sales_v2, analyze_reviews, analyze_niche,
|
| 15 |
+
generate_keywords, analyze_buy_box
|
| 16 |
+
)
|
| 17 |
+
from app.security.input_guard import scan_user_text
|
| 18 |
+
from app.utils.validators import normalize_asin
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/v2", tags=["Advanced Features"])
|
| 21 |
+
|
| 22 |
+
ALLOWED_ALERT_TYPES = {"price_drop", "price_increase", "bsr_change", "review_spike"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AlertCreate(BaseModel):
|
| 26 |
+
asin: str = Field(max_length=10)
|
| 27 |
+
alert_type: str = Field(max_length=32)
|
| 28 |
+
threshold: float = Field(ge=0, le=1_000_000)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TrackRequest(BaseModel):
|
| 32 |
+
asin: str = Field(max_length=10)
|
| 33 |
+
title: Optional[str] = Field(default=None, max_length=500)
|
| 34 |
+
price: Optional[float] = None
|
| 35 |
+
bsr: Optional[int] = None
|
| 36 |
+
reviews: Optional[int] = None
|
| 37 |
+
rating: Optional[float] = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class NicheRequest(BaseModel):
|
| 41 |
+
keyword: str = Field(max_length=120)
|
| 42 |
+
avg_bsr: float = Field(default=50000, ge=1, le=50_000_000)
|
| 43 |
+
avg_reviews: float = Field(default=200, ge=0, le=10_000_000)
|
| 44 |
+
avg_price: float = Field(default=25.0, ge=0, le=100_000)
|
| 45 |
+
product_count: int = Field(default=100, ge=1, le=100_000)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class SalesRequest(BaseModel):
|
| 49 |
+
bsr: int = Field(ge=1, le=50_000_000)
|
| 50 |
+
category: str = Field(default="default", max_length=120)
|
| 51 |
+
price: float = Field(default=25.0, ge=0, le=100_000)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ReviewRequest(BaseModel):
|
| 55 |
+
rating: float = Field(ge=0, le=5)
|
| 56 |
+
review_count: int = Field(ge=0, le=50_000_000)
|
| 57 |
+
title: str = Field(default="", max_length=500)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class BuyBoxRequest(BaseModel):
|
| 61 |
+
price: float = Field(ge=0, le=100_000)
|
| 62 |
+
rating: float = Field(default=4.5, ge=0, le=5)
|
| 63 |
+
review_count: int = Field(default=100, ge=0, le=50_000_000)
|
| 64 |
+
is_fba: bool = True
|
| 65 |
+
is_prime: bool = True
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class KeywordRequest(BaseModel):
|
| 69 |
+
seed_keyword: str = Field(max_length=120)
|
| 70 |
+
category: str = Field(default="", max_length=120)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.get("/track/{asin}/history")
|
| 74 |
+
def price_history(asin: str, days: int = 30, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 75 |
+
asin = normalize_asin(asin)
|
| 76 |
+
days = min(max(days, 1), 365)
|
| 77 |
+
history = get_price_history(db, asin, days)
|
| 78 |
+
return {"asin": asin, "days": days, "history": history, "count": len(history)}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.get("/track/list")
|
| 82 |
+
def tracked_list(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 83 |
+
tracked = db.query(TrackedProduct).filter(TrackedProduct.user_id == str(current_user.id)).all()
|
| 84 |
+
result = []
|
| 85 |
+
for t in tracked:
|
| 86 |
+
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 87 |
+
if product:
|
| 88 |
+
latest = db.query(PriceHistory).filter(PriceHistory.product_id == product.id).order_by(PriceHistory.recorded_at.desc()).first()
|
| 89 |
+
result.append({
|
| 90 |
+
"asin": product.asin,
|
| 91 |
+
"title": product.title,
|
| 92 |
+
"tracked_since": t.tracked_at.isoformat() if t.tracked_at else None,
|
| 93 |
+
"price": float(latest.price) if latest and latest.price else None,
|
| 94 |
+
"bsr": latest.bsr if latest else None,
|
| 95 |
+
})
|
| 96 |
+
return {"products": result}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.post("/alerts")
|
| 100 |
+
def create_alert_endpoint(req: AlertCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 101 |
+
asin = normalize_asin(req.asin)
|
| 102 |
+
alert_type = req.alert_type.strip().lower()
|
| 103 |
+
if alert_type not in ALLOWED_ALERT_TYPES:
|
| 104 |
+
raise HTTPException(status_code=400, detail="Invalid alert type")
|
| 105 |
+
alert = create_alert(db, str(current_user.id), asin, alert_type, req.threshold)
|
| 106 |
+
return {"created": True, "alert_id": alert.id, "type": alert_type, "threshold": req.threshold}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.get("/alerts")
|
| 110 |
+
def list_alerts(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 111 |
+
alerts = get_alerts(db, str(current_user.id))
|
| 112 |
+
return {"alerts": [{"id": a.id, "asin": a.asin, "type": a.alert_type, "threshold": a.threshold, "triggered": a.triggered} for a in alerts]}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/sales-estimate")
|
| 116 |
+
def sales_estimate(req: SalesRequest, current_user: User = Depends(get_current_user)):
|
| 117 |
+
category = scan_user_text(req.category, "category") if req.category != "default" else "default"
|
| 118 |
+
return estimate_sales_v2(req.bsr, category, req.price)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@router.post("/review-analysis")
|
| 122 |
+
def review_analysis(req: ReviewRequest, current_user: User = Depends(get_current_user)):
|
| 123 |
+
title = scan_user_text(req.title, "title") if req.title else ""
|
| 124 |
+
return analyze_reviews(req.rating, req.review_count, title)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@router.post("/buy-box")
|
| 128 |
+
def buy_box(req: BuyBoxRequest, current_user: User = Depends(get_current_user)):
|
| 129 |
+
return analyze_buy_box(req.price, req.rating, req.review_count, req.is_fba, req.is_prime)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.post("/niche")
|
| 133 |
+
def niche_finder(req: NicheRequest, current_user: User = Depends(get_current_user)):
|
| 134 |
+
keyword = scan_user_text(req.keyword, "keyword")
|
| 135 |
+
return analyze_niche(keyword, req.avg_bsr, req.avg_reviews, req.avg_price, req.product_count)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.post("/keywords")
|
| 139 |
+
def keyword_research(req: KeywordRequest, current_user: User = Depends(get_current_user)):
|
| 140 |
+
seed = scan_user_text(req.seed_keyword, "seed_keyword")
|
| 141 |
+
category = scan_user_text(req.category, "category") if req.category else ""
|
| 142 |
+
return generate_keywords(seed, category)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.get("/full-analysis/{asin}")
|
| 146 |
+
def full_analysis(asin: str, price: float = 25.0, bsr: int = 50000, reviews: int = 200, rating: float = 4.0, category: str = "default", db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 147 |
+
asin = normalize_asin(asin)
|
| 148 |
+
sales = estimate_sales_v2(bsr, category, price)
|
| 149 |
+
review_a = analyze_reviews(rating, reviews)
|
| 150 |
+
buy_box_a = analyze_buy_box(price, rating, reviews)
|
| 151 |
+
history = get_price_history(db, asin, 30)
|
| 152 |
+
return {"asin": asin, "sales_estimate": sales, "review_analysis": review_a, "buy_box": buy_box_a, "price_history": history}
|
app/routers/analysis.py
CHANGED
|
@@ -1,104 +1,104 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
-
from sqlalchemy.orm import Session
|
| 3 |
-
from app.database import get_db
|
| 4 |
-
from app.dependencies import get_current_user
|
| 5 |
-
from app.models.user import User
|
| 6 |
-
from app.models.product import Product, PriceHistory
|
| 7 |
-
from app.services.analytics.ai_analyzer import generate_ai_analysis
|
| 8 |
-
from app.services.amazon.sales_estimator import estimate_monthly_sales, calculate_opportunity_score
|
| 9 |
-
import csv
|
| 10 |
-
import io
|
| 11 |
-
from fastapi.responses import StreamingResponse
|
| 12 |
-
|
| 13 |
-
router = APIRouter(prefix="/api/analysis", tags=["Analysis"])
|
| 14 |
-
|
| 15 |
-
@router.get("/{asin}/ai")
|
| 16 |
-
def get_ai_analysis(
|
| 17 |
-
asin: str,
|
| 18 |
-
db: Session = Depends(get_db),
|
| 19 |
-
current_user: User = Depends(get_current_user)
|
| 20 |
-
):
|
| 21 |
-
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 22 |
-
if not product:
|
| 23 |
-
raise HTTPException(status_code=404, detail="Product not found. Fetch it first.")
|
| 24 |
-
|
| 25 |
-
latest = (
|
| 26 |
-
db.query(PriceHistory)
|
| 27 |
-
.filter(PriceHistory.product_id == product.id)
|
| 28 |
-
.order_by(PriceHistory.recorded_at.desc())
|
| 29 |
-
.first()
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
sales_data = estimate_monthly_sales(latest.bsr if latest else 0, product.category or "")
|
| 33 |
-
opportunity_score = None
|
| 34 |
-
if latest and sales_data["monthly_units"]:
|
| 35 |
-
opportunity_score = calculate_opportunity_score(
|
| 36 |
-
bsr=latest.bsr or 0,
|
| 37 |
-
review_count=latest.review_count or 0,
|
| 38 |
-
monthly_sales=sales_data["monthly_units"],
|
| 39 |
-
seller_count=1
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
product_data = {
|
| 43 |
-
"asin": product.asin,
|
| 44 |
-
"title": product.title,
|
| 45 |
-
"category": product.category,
|
| 46 |
-
"current_price": float(latest.price) if latest and latest.price else None,
|
| 47 |
-
"current_bsr": latest.bsr if latest else None,
|
| 48 |
-
"current_rating": float(latest.rating) if latest and latest.rating else None,
|
| 49 |
-
"current_review_count": latest.review_count if latest else None,
|
| 50 |
-
"sales_estimate_monthly": sales_data["monthly_units"],
|
| 51 |
-
"revenue_estimate_monthly": round(sales_data["monthly_units"] * float(latest.price), 2) if sales_data["monthly_units"] and latest and latest.price else None,
|
| 52 |
-
"opportunity_score": opportunity_score,
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
analysis = generate_ai_analysis(product_data)
|
| 56 |
-
return {
|
| 57 |
-
"asin": asin,
|
| 58 |
-
"product_title": product.title,
|
| 59 |
-
**analysis,
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
@router.get("/export/csv")
|
| 64 |
-
def export_tracked_csv(
|
| 65 |
-
db: Session = Depends(get_db),
|
| 66 |
-
current_user: User = Depends(get_current_user)
|
| 67 |
-
):
|
| 68 |
-
from app.models.product import TrackedProduct
|
| 69 |
-
|
| 70 |
-
tracked_list = db.query(TrackedProduct).filter(
|
| 71 |
-
TrackedProduct.user_id == current_user.id
|
| 72 |
-
).all()
|
| 73 |
-
|
| 74 |
-
output = io.StringIO()
|
| 75 |
-
writer = csv.writer(output)
|
| 76 |
-
writer.writerow(["ASIN", "Title", "Brand", "Category", "Price", "BSR", "Rating", "Reviews", "Tracked At"])
|
| 77 |
-
|
| 78 |
-
for t in tracked_list:
|
| 79 |
-
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 80 |
-
if product:
|
| 81 |
-
latest = (
|
| 82 |
-
db.query(PriceHistory)
|
| 83 |
-
.filter(PriceHistory.product_id == product.id)
|
| 84 |
-
.order_by(PriceHistory.recorded_at.desc())
|
| 85 |
-
.first()
|
| 86 |
-
)
|
| 87 |
-
writer.writerow([
|
| 88 |
-
product.asin,
|
| 89 |
-
product.title,
|
| 90 |
-
product.brand or "",
|
| 91 |
-
product.category or "",
|
| 92 |
-
float(latest.price) if latest and latest.price else "",
|
| 93 |
-
latest.bsr if latest else "",
|
| 94 |
-
float(latest.rating) if latest and latest.rating else "",
|
| 95 |
-
latest.review_count if latest else "",
|
| 96 |
-
t.tracked_at.strftime("%Y-%m-%d %H:%M") if t.tracked_at else "",
|
| 97 |
-
])
|
| 98 |
-
|
| 99 |
-
output.seek(0)
|
| 100 |
-
return StreamingResponse(
|
| 101 |
-
iter([output.getvalue()]),
|
| 102 |
-
media_type="text/csv",
|
| 103 |
-
headers={"Content-Disposition": "attachment; filename=tracked_products.csv"}
|
| 104 |
)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.database import get_db
|
| 4 |
+
from app.dependencies import get_current_user
|
| 5 |
+
from app.models.user import User
|
| 6 |
+
from app.models.product import Product, PriceHistory
|
| 7 |
+
from app.services.analytics.ai_analyzer import generate_ai_analysis
|
| 8 |
+
from app.services.amazon.sales_estimator import estimate_monthly_sales, calculate_opportunity_score
|
| 9 |
+
import csv
|
| 10 |
+
import io
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/analysis", tags=["Analysis"])
|
| 14 |
+
|
| 15 |
+
@router.get("/{asin}/ai")
|
| 16 |
+
def get_ai_analysis(
|
| 17 |
+
asin: str,
|
| 18 |
+
db: Session = Depends(get_db),
|
| 19 |
+
current_user: User = Depends(get_current_user)
|
| 20 |
+
):
|
| 21 |
+
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 22 |
+
if not product:
|
| 23 |
+
raise HTTPException(status_code=404, detail="Product not found. Fetch it first.")
|
| 24 |
+
|
| 25 |
+
latest = (
|
| 26 |
+
db.query(PriceHistory)
|
| 27 |
+
.filter(PriceHistory.product_id == product.id)
|
| 28 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 29 |
+
.first()
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
sales_data = estimate_monthly_sales(latest.bsr if latest else 0, product.category or "")
|
| 33 |
+
opportunity_score = None
|
| 34 |
+
if latest and sales_data["monthly_units"]:
|
| 35 |
+
opportunity_score = calculate_opportunity_score(
|
| 36 |
+
bsr=latest.bsr or 0,
|
| 37 |
+
review_count=latest.review_count or 0,
|
| 38 |
+
monthly_sales=sales_data["monthly_units"],
|
| 39 |
+
seller_count=1
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
product_data = {
|
| 43 |
+
"asin": product.asin,
|
| 44 |
+
"title": product.title,
|
| 45 |
+
"category": product.category,
|
| 46 |
+
"current_price": float(latest.price) if latest and latest.price else None,
|
| 47 |
+
"current_bsr": latest.bsr if latest else None,
|
| 48 |
+
"current_rating": float(latest.rating) if latest and latest.rating else None,
|
| 49 |
+
"current_review_count": latest.review_count if latest else None,
|
| 50 |
+
"sales_estimate_monthly": sales_data["monthly_units"],
|
| 51 |
+
"revenue_estimate_monthly": round(sales_data["monthly_units"] * float(latest.price), 2) if sales_data["monthly_units"] and latest and latest.price else None,
|
| 52 |
+
"opportunity_score": opportunity_score,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
analysis = generate_ai_analysis(product_data)
|
| 56 |
+
return {
|
| 57 |
+
"asin": asin,
|
| 58 |
+
"product_title": product.title,
|
| 59 |
+
**analysis,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.get("/export/csv")
|
| 64 |
+
def export_tracked_csv(
|
| 65 |
+
db: Session = Depends(get_db),
|
| 66 |
+
current_user: User = Depends(get_current_user)
|
| 67 |
+
):
|
| 68 |
+
from app.models.product import TrackedProduct
|
| 69 |
+
|
| 70 |
+
tracked_list = db.query(TrackedProduct).filter(
|
| 71 |
+
TrackedProduct.user_id == current_user.id
|
| 72 |
+
).all()
|
| 73 |
+
|
| 74 |
+
output = io.StringIO()
|
| 75 |
+
writer = csv.writer(output)
|
| 76 |
+
writer.writerow(["ASIN", "Title", "Brand", "Category", "Price", "BSR", "Rating", "Reviews", "Tracked At"])
|
| 77 |
+
|
| 78 |
+
for t in tracked_list:
|
| 79 |
+
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 80 |
+
if product:
|
| 81 |
+
latest = (
|
| 82 |
+
db.query(PriceHistory)
|
| 83 |
+
.filter(PriceHistory.product_id == product.id)
|
| 84 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 85 |
+
.first()
|
| 86 |
+
)
|
| 87 |
+
writer.writerow([
|
| 88 |
+
product.asin,
|
| 89 |
+
product.title,
|
| 90 |
+
product.brand or "",
|
| 91 |
+
product.category or "",
|
| 92 |
+
float(latest.price) if latest and latest.price else "",
|
| 93 |
+
latest.bsr if latest else "",
|
| 94 |
+
float(latest.rating) if latest and latest.rating else "",
|
| 95 |
+
latest.review_count if latest else "",
|
| 96 |
+
t.tracked_at.strftime("%Y-%m-%d %H:%M") if t.tracked_at else "",
|
| 97 |
+
])
|
| 98 |
+
|
| 99 |
+
output.seek(0)
|
| 100 |
+
return StreamingResponse(
|
| 101 |
+
iter([output.getvalue()]),
|
| 102 |
+
media_type="text/csv",
|
| 103 |
+
headers={"Content-Disposition": "attachment; filename=tracked_products.csv"}
|
| 104 |
)
|
app/routers/auth.py
CHANGED
|
@@ -1,97 +1,97 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 2 |
-
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
-
from sqlalchemy.orm import Session
|
| 4 |
-
from datetime import datetime, timedelta
|
| 5 |
-
from jose import jwt, JWTError
|
| 6 |
-
import bcrypt
|
| 7 |
-
|
| 8 |
-
from app.database import get_db
|
| 9 |
-
from app.models.user import User
|
| 10 |
-
from app.schemas.user import UserRegister, UserLogin, Token, UserResponse, LogoutResponse
|
| 11 |
-
from app.config import settings
|
| 12 |
-
from app.dependencies import get_current_user, security
|
| 13 |
-
from app.security.input_guard import assert_password_safe, scan_user_text
|
| 14 |
-
from app.security.rate_limit import enforce_auth_rate_limit
|
| 15 |
-
from app.security.token_revocation import new_jti, revoke_jti
|
| 16 |
-
|
| 17 |
-
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def hash_password(password: str) -> str:
|
| 21 |
-
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def verify_password(plain: str, hashed: str) -> bool:
|
| 25 |
-
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def create_token(user: User) -> str:
|
| 29 |
-
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
| 30 |
-
to_encode = {
|
| 31 |
-
"sub": str(user.id),
|
| 32 |
-
"email": user.email,
|
| 33 |
-
"jti": new_jti(),
|
| 34 |
-
"tv": user.token_version or 0,
|
| 35 |
-
"iat": datetime.utcnow(),
|
| 36 |
-
"exp": expire,
|
| 37 |
-
}
|
| 38 |
-
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
@router.post("/register", response_model=Token)
|
| 42 |
-
def register(user_data: UserRegister, request: Request, db: Session = Depends(get_db)):
|
| 43 |
-
enforce_auth_rate_limit(request, "register")
|
| 44 |
-
assert_password_safe(user_data.password)
|
| 45 |
-
email = str(user_data.email).lower().strip()[:254]
|
| 46 |
-
full_name = scan_user_text(user_data.full_name, "full_name") if user_data.full_name else None
|
| 47 |
-
if db.query(User).filter(User.email == email).first():
|
| 48 |
-
raise HTTPException(status_code=400, detail="Email already registered")
|
| 49 |
-
user = User(
|
| 50 |
-
email=email,
|
| 51 |
-
password_hash=hash_password(user_data.password),
|
| 52 |
-
full_name=full_name,
|
| 53 |
-
token_version=0,
|
| 54 |
-
)
|
| 55 |
-
db.add(user)
|
| 56 |
-
db.commit()
|
| 57 |
-
db.refresh(user)
|
| 58 |
-
token = create_token(user)
|
| 59 |
-
return Token(access_token=token, token_type="bearer", user=UserResponse.model_validate(user))
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
@router.post("/login", response_model=Token)
|
| 63 |
-
def login(credentials: UserLogin, request: Request, db: Session = Depends(get_db)):
|
| 64 |
-
enforce_auth_rate_limit(request, "login")
|
| 65 |
-
assert_password_safe(credentials.password)
|
| 66 |
-
email = str(credentials.email).lower().strip()[:254]
|
| 67 |
-
user = db.query(User).filter(User.email == email).first()
|
| 68 |
-
if not user or not verify_password(credentials.password, user.password_hash):
|
| 69 |
-
raise HTTPException(status_code=401, detail="Invalid email or password")
|
| 70 |
-
if not user.is_active:
|
| 71 |
-
raise HTTPException(status_code=403, detail="Account deactivated")
|
| 72 |
-
token = create_token(user)
|
| 73 |
-
return Token(access_token=token, token_type="bearer", user=UserResponse.model_validate(user))
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
@router.post("/logout", response_model=LogoutResponse)
|
| 77 |
-
def logout(
|
| 78 |
-
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 79 |
-
current_user: User = Depends(get_current_user),
|
| 80 |
-
db: Session = Depends(get_db),
|
| 81 |
-
):
|
| 82 |
-
"""Invalidate token (replay protection) — bumps token_version and revokes jti."""
|
| 83 |
-
try:
|
| 84 |
-
payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=[settings.algorithm])
|
| 85 |
-
jti = payload.get("jti")
|
| 86 |
-
exp = payload.get("exp")
|
| 87 |
-
revoke_jti(jti, float(exp) if exp else None)
|
| 88 |
-
except JWTError:
|
| 89 |
-
pass
|
| 90 |
-
current_user.token_version = (current_user.token_version or 0) + 1
|
| 91 |
-
db.commit()
|
| 92 |
-
return LogoutResponse(message="Logged out")
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
@router.get("/me", response_model=UserResponse)
|
| 96 |
-
def get_me(current_user: User = Depends(get_current_user)):
|
| 97 |
-
return current_user
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from jose import jwt, JWTError
|
| 6 |
+
import bcrypt
|
| 7 |
+
|
| 8 |
+
from app.database import get_db
|
| 9 |
+
from app.models.user import User
|
| 10 |
+
from app.schemas.user import UserRegister, UserLogin, Token, UserResponse, LogoutResponse
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.dependencies import get_current_user, security
|
| 13 |
+
from app.security.input_guard import assert_password_safe, scan_user_text
|
| 14 |
+
from app.security.rate_limit import enforce_auth_rate_limit
|
| 15 |
+
from app.security.token_revocation import new_jti, revoke_jti
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def hash_password(password: str) -> str:
|
| 21 |
+
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 25 |
+
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def create_token(user: User) -> str:
|
| 29 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
| 30 |
+
to_encode = {
|
| 31 |
+
"sub": str(user.id),
|
| 32 |
+
"email": user.email,
|
| 33 |
+
"jti": new_jti(),
|
| 34 |
+
"tv": user.token_version or 0,
|
| 35 |
+
"iat": datetime.utcnow(),
|
| 36 |
+
"exp": expire,
|
| 37 |
+
}
|
| 38 |
+
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/register", response_model=Token)
|
| 42 |
+
def register(user_data: UserRegister, request: Request, db: Session = Depends(get_db)):
|
| 43 |
+
enforce_auth_rate_limit(request, "register")
|
| 44 |
+
assert_password_safe(user_data.password)
|
| 45 |
+
email = str(user_data.email).lower().strip()[:254]
|
| 46 |
+
full_name = scan_user_text(user_data.full_name, "full_name") if user_data.full_name else None
|
| 47 |
+
if db.query(User).filter(User.email == email).first():
|
| 48 |
+
raise HTTPException(status_code=400, detail="Email already registered")
|
| 49 |
+
user = User(
|
| 50 |
+
email=email,
|
| 51 |
+
password_hash=hash_password(user_data.password),
|
| 52 |
+
full_name=full_name,
|
| 53 |
+
token_version=0,
|
| 54 |
+
)
|
| 55 |
+
db.add(user)
|
| 56 |
+
db.commit()
|
| 57 |
+
db.refresh(user)
|
| 58 |
+
token = create_token(user)
|
| 59 |
+
return Token(access_token=token, token_type="bearer", user=UserResponse.model_validate(user))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.post("/login", response_model=Token)
|
| 63 |
+
def login(credentials: UserLogin, request: Request, db: Session = Depends(get_db)):
|
| 64 |
+
enforce_auth_rate_limit(request, "login")
|
| 65 |
+
assert_password_safe(credentials.password)
|
| 66 |
+
email = str(credentials.email).lower().strip()[:254]
|
| 67 |
+
user = db.query(User).filter(User.email == email).first()
|
| 68 |
+
if not user or not verify_password(credentials.password, user.password_hash):
|
| 69 |
+
raise HTTPException(status_code=401, detail="Invalid email or password")
|
| 70 |
+
if not user.is_active:
|
| 71 |
+
raise HTTPException(status_code=403, detail="Account deactivated")
|
| 72 |
+
token = create_token(user)
|
| 73 |
+
return Token(access_token=token, token_type="bearer", user=UserResponse.model_validate(user))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.post("/logout", response_model=LogoutResponse)
|
| 77 |
+
def logout(
|
| 78 |
+
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 79 |
+
current_user: User = Depends(get_current_user),
|
| 80 |
+
db: Session = Depends(get_db),
|
| 81 |
+
):
|
| 82 |
+
"""Invalidate token (replay protection) — bumps token_version and revokes jti."""
|
| 83 |
+
try:
|
| 84 |
+
payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=[settings.algorithm])
|
| 85 |
+
jti = payload.get("jti")
|
| 86 |
+
exp = payload.get("exp")
|
| 87 |
+
revoke_jti(jti, float(exp) if exp else None)
|
| 88 |
+
except JWTError:
|
| 89 |
+
pass
|
| 90 |
+
current_user.token_version = (current_user.token_version or 0) + 1
|
| 91 |
+
db.commit()
|
| 92 |
+
return LogoutResponse(message="Logged out")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.get("/me", response_model=UserResponse)
|
| 96 |
+
def get_me(current_user: User = Depends(get_current_user)):
|
| 97 |
+
return current_user
|
app/routers/competitors.py
CHANGED
|
@@ -1,45 +1,45 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
-
from sqlalchemy.orm import Session
|
| 3 |
-
|
| 4 |
-
from app.database import get_db
|
| 5 |
-
from app.dependencies import get_current_user
|
| 6 |
-
from app.models.user import User
|
| 7 |
-
from app.models.product import Product, PriceHistory
|
| 8 |
-
from app.services.amazon.competitor_service import get_competitors_for_product
|
| 9 |
-
from app.utils.validators import normalize_asin
|
| 10 |
-
|
| 11 |
-
router = APIRouter(prefix="/api/competitors", tags=["Competitors"])
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@router.get("/{asin}")
|
| 15 |
-
def get_competitors(
|
| 16 |
-
asin: str,
|
| 17 |
-
db: Session = Depends(get_db),
|
| 18 |
-
current_user: User = Depends(get_current_user),
|
| 19 |
-
):
|
| 20 |
-
asin = normalize_asin(asin)
|
| 21 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 22 |
-
if not product:
|
| 23 |
-
raise HTTPException(status_code=404, detail="Product not found. Fetch it first.")
|
| 24 |
-
|
| 25 |
-
latest = (
|
| 26 |
-
db.query(PriceHistory)
|
| 27 |
-
.filter(PriceHistory.product_id == product.id)
|
| 28 |
-
.order_by(PriceHistory.recorded_at.desc())
|
| 29 |
-
.first()
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
competitors, data_source = get_competitors_for_product(
|
| 33 |
-
asin, product.category or "default", product.title or ""
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
return {
|
| 37 |
-
"asin": asin,
|
| 38 |
-
"product_title": product.title,
|
| 39 |
-
"product_price": float(latest.price) if latest and latest.price else None,
|
| 40 |
-
"product_bsr": latest.bsr if latest else None,
|
| 41 |
-
"product_rating": float(latest.rating) if latest and latest.rating else None,
|
| 42 |
-
"competitors": competitors,
|
| 43 |
-
"total_competitors": len(competitors),
|
| 44 |
-
"data_source": data_source,
|
| 45 |
-
}
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
|
| 4 |
+
from app.database import get_db
|
| 5 |
+
from app.dependencies import get_current_user
|
| 6 |
+
from app.models.user import User
|
| 7 |
+
from app.models.product import Product, PriceHistory
|
| 8 |
+
from app.services.amazon.competitor_service import get_competitors_for_product
|
| 9 |
+
from app.utils.validators import normalize_asin
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/competitors", tags=["Competitors"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/{asin}")
|
| 15 |
+
def get_competitors(
|
| 16 |
+
asin: str,
|
| 17 |
+
db: Session = Depends(get_db),
|
| 18 |
+
current_user: User = Depends(get_current_user),
|
| 19 |
+
):
|
| 20 |
+
asin = normalize_asin(asin)
|
| 21 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 22 |
+
if not product:
|
| 23 |
+
raise HTTPException(status_code=404, detail="Product not found. Fetch it first.")
|
| 24 |
+
|
| 25 |
+
latest = (
|
| 26 |
+
db.query(PriceHistory)
|
| 27 |
+
.filter(PriceHistory.product_id == product.id)
|
| 28 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 29 |
+
.first()
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
competitors, data_source = get_competitors_for_product(
|
| 33 |
+
asin, product.category or "default", product.title or ""
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
return {
|
| 37 |
+
"asin": asin,
|
| 38 |
+
"product_title": product.title,
|
| 39 |
+
"product_price": float(latest.price) if latest and latest.price else None,
|
| 40 |
+
"product_bsr": latest.bsr if latest else None,
|
| 41 |
+
"product_rating": float(latest.rating) if latest and latest.rating else None,
|
| 42 |
+
"competitors": competitors,
|
| 43 |
+
"total_competitors": len(competitors),
|
| 44 |
+
"data_source": data_source,
|
| 45 |
+
}
|
app/routers/keywords.py
CHANGED
|
@@ -1,31 +1,31 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
-
from sqlalchemy.orm import Session
|
| 3 |
-
from app.database import get_db
|
| 4 |
-
from app.dependencies import get_current_user
|
| 5 |
-
from app.models.user import User
|
| 6 |
-
from app.models.product import Product
|
| 7 |
-
from app.services.amazon.keyword_service import get_keywords_for_product
|
| 8 |
-
|
| 9 |
-
from app.utils.validators import normalize_asin
|
| 10 |
-
|
| 11 |
-
router = APIRouter(prefix="/api/keywords", tags=["Keywords"])
|
| 12 |
-
|
| 13 |
-
@router.get("/{asin}")
|
| 14 |
-
def get_keywords(
|
| 15 |
-
asin: str,
|
| 16 |
-
db: Session = Depends(get_db),
|
| 17 |
-
current_user: User = Depends(get_current_user)
|
| 18 |
-
):
|
| 19 |
-
asin = normalize_asin(asin)
|
| 20 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 21 |
-
if not product:
|
| 22 |
-
raise HTTPException(status_code=404, detail="Product not found. Fetch it first via /api/products/{asin}")
|
| 23 |
-
|
| 24 |
-
keywords = get_keywords_for_product(product.title, asin)
|
| 25 |
-
|
| 26 |
-
return {
|
| 27 |
-
"asin": asin,
|
| 28 |
-
"product_title": product.title,
|
| 29 |
-
"keywords": keywords,
|
| 30 |
-
"total": len(keywords)
|
| 31 |
}
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.database import get_db
|
| 4 |
+
from app.dependencies import get_current_user
|
| 5 |
+
from app.models.user import User
|
| 6 |
+
from app.models.product import Product
|
| 7 |
+
from app.services.amazon.keyword_service import get_keywords_for_product
|
| 8 |
+
|
| 9 |
+
from app.utils.validators import normalize_asin
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/keywords", tags=["Keywords"])
|
| 12 |
+
|
| 13 |
+
@router.get("/{asin}")
|
| 14 |
+
def get_keywords(
|
| 15 |
+
asin: str,
|
| 16 |
+
db: Session = Depends(get_db),
|
| 17 |
+
current_user: User = Depends(get_current_user)
|
| 18 |
+
):
|
| 19 |
+
asin = normalize_asin(asin)
|
| 20 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 21 |
+
if not product:
|
| 22 |
+
raise HTTPException(status_code=404, detail="Product not found. Fetch it first via /api/products/{asin}")
|
| 23 |
+
|
| 24 |
+
keywords = get_keywords_for_product(product.title, asin)
|
| 25 |
+
|
| 26 |
+
return {
|
| 27 |
+
"asin": asin,
|
| 28 |
+
"product_title": product.title,
|
| 29 |
+
"keywords": keywords,
|
| 30 |
+
"total": len(keywords)
|
| 31 |
}
|
app/routers/ml_router.py
CHANGED
|
@@ -1,277 +1,277 @@
|
|
| 1 |
-
# app/routers/ml_router.py
|
| 2 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
-
# ML Analysis Router — 5 endpoints
|
| 4 |
-
# GET /api/ml/fake-reviews/{asin} — fake review detection
|
| 5 |
-
# GET /api/ml/price-prediction/{asin} — price forecast
|
| 6 |
-
# GET /api/ml/demand-forecast/{asin} — demand forecast
|
| 7 |
-
# GET /api/ml/niche-score/{asin} — niche opportunity score
|
| 8 |
-
# GET /api/ml/full-analysis/{asin} — all 4 combined
|
| 9 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 10 |
-
|
| 11 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 12 |
-
from sqlalchemy.orm import Session
|
| 13 |
-
|
| 14 |
-
from app.database import get_db
|
| 15 |
-
from app.dependencies import get_current_user
|
| 16 |
-
from app.models.user import User
|
| 17 |
-
from app.models.product import Product, PriceHistory, TrackedProduct
|
| 18 |
-
from app.services.ml.fake_review_detector import detect_fake_reviews
|
| 19 |
-
from app.services.ml.price_predictor import predict_price
|
| 20 |
-
from app.services.ml.demand_forecaster import forecast_demand
|
| 21 |
-
from app.services.ml.niche_scorer import calculate_niche_score
|
| 22 |
-
|
| 23 |
-
router = APIRouter(prefix="/api/ml", tags=["ML Features"])
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
# ── Helper: load product + latest data from DB ─────────────────────────────
|
| 27 |
-
def _load_product_data(asin: str, db: Session) -> dict:
|
| 28 |
-
"""
|
| 29 |
-
Load product and its price history from the database.
|
| 30 |
-
Returns a unified dict of all available fields.
|
| 31 |
-
Raises 404 if product not found.
|
| 32 |
-
"""
|
| 33 |
-
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 34 |
-
if not product:
|
| 35 |
-
raise HTTPException(
|
| 36 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 37 |
-
detail=f"Product {asin} not found. Search for it first using GET /api/products/{asin}",
|
| 38 |
-
)
|
| 39 |
-
|
| 40 |
-
# Load price history (last 30 records)
|
| 41 |
-
history_rows = (
|
| 42 |
-
db.query(PriceHistory)
|
| 43 |
-
.filter(PriceHistory.product_id == str(product.id))
|
| 44 |
-
.order_by(PriceHistory.recorded_at.desc())
|
| 45 |
-
.limit(30)
|
| 46 |
-
.all()
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
# Latest snapshot
|
| 50 |
-
latest = history_rows[0] if history_rows else None
|
| 51 |
-
|
| 52 |
-
# Serialised history for ML models
|
| 53 |
-
history_list = [
|
| 54 |
-
{
|
| 55 |
-
"price": float(h.price) if h.price else None,
|
| 56 |
-
"bsr": h.bsr,
|
| 57 |
-
"rating": float(h.rating) if h.rating else None,
|
| 58 |
-
"review_count": h.review_count,
|
| 59 |
-
"recorded_at": h.recorded_at.isoformat(),
|
| 60 |
-
}
|
| 61 |
-
for h in reversed(history_rows)
|
| 62 |
-
]
|
| 63 |
-
|
| 64 |
-
# Estimate monthly sales from BSR (reuse your existing estimator)
|
| 65 |
-
from app.services.amazon.sales_estimator import estimate_monthly_sales
|
| 66 |
-
bsr = latest.bsr if latest else None
|
| 67 |
-
category = product.category or ""
|
| 68 |
-
sales_data = estimate_monthly_sales(bsr or 0, category)
|
| 69 |
-
monthly_sales = sales_data.get("monthly_units", 0)
|
| 70 |
-
|
| 71 |
-
# Price — handle PKR→USD
|
| 72 |
-
raw_price = float(latest.price) if latest and latest.price else 0.0
|
| 73 |
-
PKR_THRESHOLD = 500
|
| 74 |
-
PKR_TO_USD = 278.0
|
| 75 |
-
price = round(raw_price / PKR_TO_USD, 2) if raw_price > PKR_THRESHOLD else raw_price
|
| 76 |
-
|
| 77 |
-
return {
|
| 78 |
-
"asin": product.asin,
|
| 79 |
-
"title": product.title,
|
| 80 |
-
"category": category,
|
| 81 |
-
"is_fba": product.is_prime, # prime = FBA proxy
|
| 82 |
-
"is_prime": product.is_prime,
|
| 83 |
-
"price": price,
|
| 84 |
-
"bsr": bsr,
|
| 85 |
-
"rating": float(latest.rating) if latest and latest.rating else 0.0,
|
| 86 |
-
"review_count": latest.review_count if latest else 0,
|
| 87 |
-
"in_stock": latest.in_stock if latest else True,
|
| 88 |
-
"monthly_sales": monthly_sales,
|
| 89 |
-
"seller_count": product.seller_count or 1,
|
| 90 |
-
"history": history_list,
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
# ── 1. Fake Review Detection ─────────────────────────────────────────────────
|
| 95 |
-
@router.get(
|
| 96 |
-
"/fake-reviews/{asin}",
|
| 97 |
-
summary="Detect fake review patterns for a product",
|
| 98 |
-
)
|
| 99 |
-
def fake_review_analysis(
|
| 100 |
-
asin: str,
|
| 101 |
-
db: Session = Depends(get_db),
|
| 102 |
-
_: User = Depends(get_current_user),
|
| 103 |
-
):
|
| 104 |
-
data = _load_product_data(asin, db)
|
| 105 |
-
result = detect_fake_reviews(
|
| 106 |
-
review_count = data["review_count"],
|
| 107 |
-
rating = data["rating"],
|
| 108 |
-
bsr = data["bsr"],
|
| 109 |
-
monthly_sales = data["monthly_sales"],
|
| 110 |
-
seller_count = data["seller_count"],
|
| 111 |
-
is_fba = data["is_fba"],
|
| 112 |
-
)
|
| 113 |
-
return {
|
| 114 |
-
"asin": asin,
|
| 115 |
-
"title": data["title"],
|
| 116 |
-
"analysis": result,
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
# ── 2. Price Prediction ───────────────────────────────────────────────────────
|
| 121 |
-
@router.get(
|
| 122 |
-
"/price-prediction/{asin}",
|
| 123 |
-
summary="Predict price movement for next 30/60/90 days",
|
| 124 |
-
)
|
| 125 |
-
def price_prediction(
|
| 126 |
-
asin: str,
|
| 127 |
-
db: Session = Depends(get_db),
|
| 128 |
-
_: User = Depends(get_current_user),
|
| 129 |
-
):
|
| 130 |
-
data = _load_product_data(asin, db)
|
| 131 |
-
result = predict_price(
|
| 132 |
-
current_price = data["price"],
|
| 133 |
-
bsr = data["bsr"],
|
| 134 |
-
rating = data["rating"],
|
| 135 |
-
review_count = data["review_count"],
|
| 136 |
-
seller_count = data["seller_count"],
|
| 137 |
-
category = data["category"],
|
| 138 |
-
price_history = data["history"],
|
| 139 |
-
)
|
| 140 |
-
return {
|
| 141 |
-
"asin": asin,
|
| 142 |
-
"title": data["title"],
|
| 143 |
-
"prediction": result,
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
# ── 3. Demand Forecast ────────────────────────────────────────────────────────
|
| 148 |
-
@router.get(
|
| 149 |
-
"/demand-forecast/{asin}",
|
| 150 |
-
summary="Forecast monthly demand for next 3 months",
|
| 151 |
-
)
|
| 152 |
-
def demand_forecast(
|
| 153 |
-
asin: str,
|
| 154 |
-
db: Session = Depends(get_db),
|
| 155 |
-
_: User = Depends(get_current_user),
|
| 156 |
-
):
|
| 157 |
-
data = _load_product_data(asin, db)
|
| 158 |
-
result = forecast_demand(
|
| 159 |
-
bsr = data["bsr"],
|
| 160 |
-
category = data["category"],
|
| 161 |
-
review_count = data["review_count"],
|
| 162 |
-
rating = data["rating"],
|
| 163 |
-
price = data["price"],
|
| 164 |
-
history = data["history"],
|
| 165 |
-
)
|
| 166 |
-
return {
|
| 167 |
-
"asin": asin,
|
| 168 |
-
"title": data["title"],
|
| 169 |
-
"forecast": result,
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
# ── 4. Niche Score ────────────────────────────────────────────────────────────
|
| 174 |
-
@router.get(
|
| 175 |
-
"/niche-score/{asin}",
|
| 176 |
-
summary="Calculate niche opportunity score (0-100)",
|
| 177 |
-
)
|
| 178 |
-
def niche_score(
|
| 179 |
-
asin: str,
|
| 180 |
-
db: Session = Depends(get_db),
|
| 181 |
-
_: User = Depends(get_current_user),
|
| 182 |
-
):
|
| 183 |
-
data = _load_product_data(asin, db)
|
| 184 |
-
result = calculate_niche_score(
|
| 185 |
-
bsr = data["bsr"],
|
| 186 |
-
review_count = data["review_count"],
|
| 187 |
-
rating = data["rating"],
|
| 188 |
-
price = data["price"],
|
| 189 |
-
monthly_sales = data["monthly_sales"],
|
| 190 |
-
seller_count = data["seller_count"],
|
| 191 |
-
is_fba = data["is_fba"],
|
| 192 |
-
category = data["category"],
|
| 193 |
-
price_history = data["history"],
|
| 194 |
-
)
|
| 195 |
-
return {
|
| 196 |
-
"asin": asin,
|
| 197 |
-
"title": data["title"],
|
| 198 |
-
"result": result,
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
# ── 5. Full ML Analysis (all 4 combined) ──────────────────────────────────────
|
| 203 |
-
@router.get(
|
| 204 |
-
"/full-analysis/{asin}",
|
| 205 |
-
summary="Run all 4 ML models in one request",
|
| 206 |
-
)
|
| 207 |
-
def full_ml_analysis(
|
| 208 |
-
asin: str,
|
| 209 |
-
db: Session = Depends(get_db),
|
| 210 |
-
_: User = Depends(get_current_user),
|
| 211 |
-
):
|
| 212 |
-
data = _load_product_data(asin, db)
|
| 213 |
-
|
| 214 |
-
fake_reviews = detect_fake_reviews(
|
| 215 |
-
review_count = data["review_count"],
|
| 216 |
-
rating = data["rating"],
|
| 217 |
-
bsr = data["bsr"],
|
| 218 |
-
monthly_sales = data["monthly_sales"],
|
| 219 |
-
seller_count = data["seller_count"],
|
| 220 |
-
is_fba = data["is_fba"],
|
| 221 |
-
)
|
| 222 |
-
price_pred = predict_price(
|
| 223 |
-
current_price = data["price"],
|
| 224 |
-
bsr = data["bsr"],
|
| 225 |
-
rating = data["rating"],
|
| 226 |
-
review_count = data["review_count"],
|
| 227 |
-
seller_count = data["seller_count"],
|
| 228 |
-
category = data["category"],
|
| 229 |
-
price_history = data["history"],
|
| 230 |
-
)
|
| 231 |
-
demand = forecast_demand(
|
| 232 |
-
bsr = data["bsr"],
|
| 233 |
-
category = data["category"],
|
| 234 |
-
review_count = data["review_count"],
|
| 235 |
-
rating = data["rating"],
|
| 236 |
-
price = data["price"],
|
| 237 |
-
history = data["history"],
|
| 238 |
-
)
|
| 239 |
-
niche = calculate_niche_score(
|
| 240 |
-
bsr = data["bsr"],
|
| 241 |
-
review_count = data["review_count"],
|
| 242 |
-
rating = data["rating"],
|
| 243 |
-
price = data["price"],
|
| 244 |
-
monthly_sales = data["monthly_sales"],
|
| 245 |
-
seller_count = data["seller_count"],
|
| 246 |
-
is_fba = data["is_fba"],
|
| 247 |
-
category = data["category"],
|
| 248 |
-
price_history = data["history"],
|
| 249 |
-
)
|
| 250 |
-
|
| 251 |
-
return {
|
| 252 |
-
"asin": asin,
|
| 253 |
-
"title": data["title"],
|
| 254 |
-
"product_snapshot": {
|
| 255 |
-
"price": data["price"],
|
| 256 |
-
"bsr": data["bsr"],
|
| 257 |
-
"rating": data["rating"],
|
| 258 |
-
"review_count": data["review_count"],
|
| 259 |
-
"monthly_sales": data["monthly_sales"],
|
| 260 |
-
"category": data["category"],
|
| 261 |
-
},
|
| 262 |
-
"ml_engine": "sklearn+heuristic" if _sklearn_ready() else "heuristic",
|
| 263 |
-
"ml_results": {
|
| 264 |
-
"fake_reviews": fake_reviews,
|
| 265 |
-
"price_forecast": price_pred,
|
| 266 |
-
"demand": demand,
|
| 267 |
-
"niche": niche,
|
| 268 |
-
},
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
def _sklearn_ready() -> bool:
|
| 273 |
-
try:
|
| 274 |
-
from app.services.ml.sklearn_engine import is_sklearn_available
|
| 275 |
-
return is_sklearn_available()
|
| 276 |
-
except Exception:
|
| 277 |
return False
|
|
|
|
| 1 |
+
# app/routers/ml_router.py
|
| 2 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# ML Analysis Router — 5 endpoints
|
| 4 |
+
# GET /api/ml/fake-reviews/{asin} — fake review detection
|
| 5 |
+
# GET /api/ml/price-prediction/{asin} — price forecast
|
| 6 |
+
# GET /api/ml/demand-forecast/{asin} — demand forecast
|
| 7 |
+
# GET /api/ml/niche-score/{asin} — niche opportunity score
|
| 8 |
+
# GET /api/ml/full-analysis/{asin} — all 4 combined
|
| 9 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from app.database import get_db
|
| 15 |
+
from app.dependencies import get_current_user
|
| 16 |
+
from app.models.user import User
|
| 17 |
+
from app.models.product import Product, PriceHistory, TrackedProduct
|
| 18 |
+
from app.services.ml.fake_review_detector import detect_fake_reviews
|
| 19 |
+
from app.services.ml.price_predictor import predict_price
|
| 20 |
+
from app.services.ml.demand_forecaster import forecast_demand
|
| 21 |
+
from app.services.ml.niche_scorer import calculate_niche_score
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/api/ml", tags=["ML Features"])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ── Helper: load product + latest data from DB ─────────────────────────────
|
| 27 |
+
def _load_product_data(asin: str, db: Session) -> dict:
|
| 28 |
+
"""
|
| 29 |
+
Load product and its price history from the database.
|
| 30 |
+
Returns a unified dict of all available fields.
|
| 31 |
+
Raises 404 if product not found.
|
| 32 |
+
"""
|
| 33 |
+
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 34 |
+
if not product:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 37 |
+
detail=f"Product {asin} not found. Search for it first using GET /api/products/{asin}",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Load price history (last 30 records)
|
| 41 |
+
history_rows = (
|
| 42 |
+
db.query(PriceHistory)
|
| 43 |
+
.filter(PriceHistory.product_id == str(product.id))
|
| 44 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 45 |
+
.limit(30)
|
| 46 |
+
.all()
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Latest snapshot
|
| 50 |
+
latest = history_rows[0] if history_rows else None
|
| 51 |
+
|
| 52 |
+
# Serialised history for ML models
|
| 53 |
+
history_list = [
|
| 54 |
+
{
|
| 55 |
+
"price": float(h.price) if h.price else None,
|
| 56 |
+
"bsr": h.bsr,
|
| 57 |
+
"rating": float(h.rating) if h.rating else None,
|
| 58 |
+
"review_count": h.review_count,
|
| 59 |
+
"recorded_at": h.recorded_at.isoformat(),
|
| 60 |
+
}
|
| 61 |
+
for h in reversed(history_rows)
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
# Estimate monthly sales from BSR (reuse your existing estimator)
|
| 65 |
+
from app.services.amazon.sales_estimator import estimate_monthly_sales
|
| 66 |
+
bsr = latest.bsr if latest else None
|
| 67 |
+
category = product.category or ""
|
| 68 |
+
sales_data = estimate_monthly_sales(bsr or 0, category)
|
| 69 |
+
monthly_sales = sales_data.get("monthly_units", 0)
|
| 70 |
+
|
| 71 |
+
# Price — handle PKR→USD
|
| 72 |
+
raw_price = float(latest.price) if latest and latest.price else 0.0
|
| 73 |
+
PKR_THRESHOLD = 500
|
| 74 |
+
PKR_TO_USD = 278.0
|
| 75 |
+
price = round(raw_price / PKR_TO_USD, 2) if raw_price > PKR_THRESHOLD else raw_price
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
"asin": product.asin,
|
| 79 |
+
"title": product.title,
|
| 80 |
+
"category": category,
|
| 81 |
+
"is_fba": product.is_prime, # prime = FBA proxy
|
| 82 |
+
"is_prime": product.is_prime,
|
| 83 |
+
"price": price,
|
| 84 |
+
"bsr": bsr,
|
| 85 |
+
"rating": float(latest.rating) if latest and latest.rating else 0.0,
|
| 86 |
+
"review_count": latest.review_count if latest else 0,
|
| 87 |
+
"in_stock": latest.in_stock if latest else True,
|
| 88 |
+
"monthly_sales": monthly_sales,
|
| 89 |
+
"seller_count": product.seller_count or 1,
|
| 90 |
+
"history": history_list,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ── 1. Fake Review Detection ─────────────────────────────────────────────────
|
| 95 |
+
@router.get(
|
| 96 |
+
"/fake-reviews/{asin}",
|
| 97 |
+
summary="Detect fake review patterns for a product",
|
| 98 |
+
)
|
| 99 |
+
def fake_review_analysis(
|
| 100 |
+
asin: str,
|
| 101 |
+
db: Session = Depends(get_db),
|
| 102 |
+
_: User = Depends(get_current_user),
|
| 103 |
+
):
|
| 104 |
+
data = _load_product_data(asin, db)
|
| 105 |
+
result = detect_fake_reviews(
|
| 106 |
+
review_count = data["review_count"],
|
| 107 |
+
rating = data["rating"],
|
| 108 |
+
bsr = data["bsr"],
|
| 109 |
+
monthly_sales = data["monthly_sales"],
|
| 110 |
+
seller_count = data["seller_count"],
|
| 111 |
+
is_fba = data["is_fba"],
|
| 112 |
+
)
|
| 113 |
+
return {
|
| 114 |
+
"asin": asin,
|
| 115 |
+
"title": data["title"],
|
| 116 |
+
"analysis": result,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ── 2. Price Prediction ───────────────────────────────────────────────────────
|
| 121 |
+
@router.get(
|
| 122 |
+
"/price-prediction/{asin}",
|
| 123 |
+
summary="Predict price movement for next 30/60/90 days",
|
| 124 |
+
)
|
| 125 |
+
def price_prediction(
|
| 126 |
+
asin: str,
|
| 127 |
+
db: Session = Depends(get_db),
|
| 128 |
+
_: User = Depends(get_current_user),
|
| 129 |
+
):
|
| 130 |
+
data = _load_product_data(asin, db)
|
| 131 |
+
result = predict_price(
|
| 132 |
+
current_price = data["price"],
|
| 133 |
+
bsr = data["bsr"],
|
| 134 |
+
rating = data["rating"],
|
| 135 |
+
review_count = data["review_count"],
|
| 136 |
+
seller_count = data["seller_count"],
|
| 137 |
+
category = data["category"],
|
| 138 |
+
price_history = data["history"],
|
| 139 |
+
)
|
| 140 |
+
return {
|
| 141 |
+
"asin": asin,
|
| 142 |
+
"title": data["title"],
|
| 143 |
+
"prediction": result,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ── 3. Demand Forecast ────────────────────────────────────────────────────────
|
| 148 |
+
@router.get(
|
| 149 |
+
"/demand-forecast/{asin}",
|
| 150 |
+
summary="Forecast monthly demand for next 3 months",
|
| 151 |
+
)
|
| 152 |
+
def demand_forecast(
|
| 153 |
+
asin: str,
|
| 154 |
+
db: Session = Depends(get_db),
|
| 155 |
+
_: User = Depends(get_current_user),
|
| 156 |
+
):
|
| 157 |
+
data = _load_product_data(asin, db)
|
| 158 |
+
result = forecast_demand(
|
| 159 |
+
bsr = data["bsr"],
|
| 160 |
+
category = data["category"],
|
| 161 |
+
review_count = data["review_count"],
|
| 162 |
+
rating = data["rating"],
|
| 163 |
+
price = data["price"],
|
| 164 |
+
history = data["history"],
|
| 165 |
+
)
|
| 166 |
+
return {
|
| 167 |
+
"asin": asin,
|
| 168 |
+
"title": data["title"],
|
| 169 |
+
"forecast": result,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ── 4. Niche Score ────────────────────────────────────────────────────────────
|
| 174 |
+
@router.get(
|
| 175 |
+
"/niche-score/{asin}",
|
| 176 |
+
summary="Calculate niche opportunity score (0-100)",
|
| 177 |
+
)
|
| 178 |
+
def niche_score(
|
| 179 |
+
asin: str,
|
| 180 |
+
db: Session = Depends(get_db),
|
| 181 |
+
_: User = Depends(get_current_user),
|
| 182 |
+
):
|
| 183 |
+
data = _load_product_data(asin, db)
|
| 184 |
+
result = calculate_niche_score(
|
| 185 |
+
bsr = data["bsr"],
|
| 186 |
+
review_count = data["review_count"],
|
| 187 |
+
rating = data["rating"],
|
| 188 |
+
price = data["price"],
|
| 189 |
+
monthly_sales = data["monthly_sales"],
|
| 190 |
+
seller_count = data["seller_count"],
|
| 191 |
+
is_fba = data["is_fba"],
|
| 192 |
+
category = data["category"],
|
| 193 |
+
price_history = data["history"],
|
| 194 |
+
)
|
| 195 |
+
return {
|
| 196 |
+
"asin": asin,
|
| 197 |
+
"title": data["title"],
|
| 198 |
+
"result": result,
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ── 5. Full ML Analysis (all 4 combined) ──────────────────────────────────────
|
| 203 |
+
@router.get(
|
| 204 |
+
"/full-analysis/{asin}",
|
| 205 |
+
summary="Run all 4 ML models in one request",
|
| 206 |
+
)
|
| 207 |
+
def full_ml_analysis(
|
| 208 |
+
asin: str,
|
| 209 |
+
db: Session = Depends(get_db),
|
| 210 |
+
_: User = Depends(get_current_user),
|
| 211 |
+
):
|
| 212 |
+
data = _load_product_data(asin, db)
|
| 213 |
+
|
| 214 |
+
fake_reviews = detect_fake_reviews(
|
| 215 |
+
review_count = data["review_count"],
|
| 216 |
+
rating = data["rating"],
|
| 217 |
+
bsr = data["bsr"],
|
| 218 |
+
monthly_sales = data["monthly_sales"],
|
| 219 |
+
seller_count = data["seller_count"],
|
| 220 |
+
is_fba = data["is_fba"],
|
| 221 |
+
)
|
| 222 |
+
price_pred = predict_price(
|
| 223 |
+
current_price = data["price"],
|
| 224 |
+
bsr = data["bsr"],
|
| 225 |
+
rating = data["rating"],
|
| 226 |
+
review_count = data["review_count"],
|
| 227 |
+
seller_count = data["seller_count"],
|
| 228 |
+
category = data["category"],
|
| 229 |
+
price_history = data["history"],
|
| 230 |
+
)
|
| 231 |
+
demand = forecast_demand(
|
| 232 |
+
bsr = data["bsr"],
|
| 233 |
+
category = data["category"],
|
| 234 |
+
review_count = data["review_count"],
|
| 235 |
+
rating = data["rating"],
|
| 236 |
+
price = data["price"],
|
| 237 |
+
history = data["history"],
|
| 238 |
+
)
|
| 239 |
+
niche = calculate_niche_score(
|
| 240 |
+
bsr = data["bsr"],
|
| 241 |
+
review_count = data["review_count"],
|
| 242 |
+
rating = data["rating"],
|
| 243 |
+
price = data["price"],
|
| 244 |
+
monthly_sales = data["monthly_sales"],
|
| 245 |
+
seller_count = data["seller_count"],
|
| 246 |
+
is_fba = data["is_fba"],
|
| 247 |
+
category = data["category"],
|
| 248 |
+
price_history = data["history"],
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
"asin": asin,
|
| 253 |
+
"title": data["title"],
|
| 254 |
+
"product_snapshot": {
|
| 255 |
+
"price": data["price"],
|
| 256 |
+
"bsr": data["bsr"],
|
| 257 |
+
"rating": data["rating"],
|
| 258 |
+
"review_count": data["review_count"],
|
| 259 |
+
"monthly_sales": data["monthly_sales"],
|
| 260 |
+
"category": data["category"],
|
| 261 |
+
},
|
| 262 |
+
"ml_engine": "sklearn+heuristic" if _sklearn_ready() else "heuristic",
|
| 263 |
+
"ml_results": {
|
| 264 |
+
"fake_reviews": fake_reviews,
|
| 265 |
+
"price_forecast": price_pred,
|
| 266 |
+
"demand": demand,
|
| 267 |
+
"niche": niche,
|
| 268 |
+
},
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _sklearn_ready() -> bool:
|
| 273 |
+
try:
|
| 274 |
+
from app.services.ml.sklearn_engine import is_sklearn_available
|
| 275 |
+
return is_sklearn_available()
|
| 276 |
+
except Exception:
|
| 277 |
return False
|
app/routers/products.py
CHANGED
|
@@ -1,233 +1,233 @@
|
|
| 1 |
-
import uuid
|
| 2 |
-
from datetime import datetime, timezone
|
| 3 |
-
|
| 4 |
-
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
| 5 |
-
from sqlalchemy.orm import Session
|
| 6 |
-
|
| 7 |
-
from app.database import get_db
|
| 8 |
-
from app.dependencies import get_current_user, enforce_search_limit
|
| 9 |
-
from app.models.user import User
|
| 10 |
-
from app.models.product import Product, PriceHistory, TrackedProduct
|
| 11 |
-
from app.services.product_service import fetch_and_sync_product, build_response
|
| 12 |
-
from app.services.analytics.buy_box_history import build_buy_box_history_payload
|
| 13 |
-
from app.services.amazon.product_scraper import search_asin_by_keyword
|
| 14 |
-
from app.services.amazon.keyword_service import get_amazon_suggestions
|
| 15 |
-
from app.services.supplier_service import find_suppliers
|
| 16 |
-
from app.utils.validators import normalize_asin
|
| 17 |
-
from app.security.input_guard import scan_user_text
|
| 18 |
-
from app.security.rate_limit import enforce_scrape_rate_limit
|
| 19 |
-
|
| 20 |
-
router = APIRouter(prefix="/api/products", tags=["Products"])
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
@router.get("/search/text")
|
| 24 |
-
def search_by_keyword(
|
| 25 |
-
q: str = Query(..., min_length=2, max_length=120),
|
| 26 |
-
db: Session = Depends(get_db),
|
| 27 |
-
current_user: User = Depends(get_current_user),
|
| 28 |
-
):
|
| 29 |
-
"""Resolve a keyword to an ASIN via Amazon search, with autocomplete suggestions."""
|
| 30 |
-
enforce_search_limit(current_user, db)
|
| 31 |
-
q = scan_user_text(q.strip(), "search query")
|
| 32 |
-
asin = search_asin_by_keyword(q)
|
| 33 |
-
suggestions = get_amazon_suggestions(q)
|
| 34 |
-
if not asin and suggestions:
|
| 35 |
-
asin = search_asin_by_keyword(suggestions[0])
|
| 36 |
-
if not asin:
|
| 37 |
-
raise HTTPException(status_code=404, detail="No products found for that keyword")
|
| 38 |
-
asin = normalize_asin(asin)
|
| 39 |
-
result = fetch_and_sync_product(db, asin, force=True)
|
| 40 |
-
if not result:
|
| 41 |
-
raise HTTPException(status_code=404, detail="Product not found")
|
| 42 |
-
result["matched_keyword"] = q
|
| 43 |
-
result["suggestions"] = suggestions[:8]
|
| 44 |
-
return result
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
@router.get("/tracked/list")
|
| 48 |
-
def get_tracked(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 49 |
-
tracked_list = db.query(TrackedProduct).filter(TrackedProduct.user_id == str(current_user.id)).all()
|
| 50 |
-
result = []
|
| 51 |
-
for t in tracked_list:
|
| 52 |
-
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 53 |
-
if product:
|
| 54 |
-
latest = (
|
| 55 |
-
db.query(PriceHistory)
|
| 56 |
-
.filter(PriceHistory.product_id == product.id)
|
| 57 |
-
.order_by(PriceHistory.recorded_at.desc())
|
| 58 |
-
.first()
|
| 59 |
-
)
|
| 60 |
-
from app.services.amazon.sales_estimator import estimate_monthly_sales
|
| 61 |
-
sales_data = estimate_monthly_sales(latest.bsr if latest else 0, product.category or "")
|
| 62 |
-
price_val = float(latest.price) if latest and latest.price else None
|
| 63 |
-
result.append({
|
| 64 |
-
"asin": product.asin,
|
| 65 |
-
"title": product.title,
|
| 66 |
-
"brand": product.brand,
|
| 67 |
-
"upc": product.upc,
|
| 68 |
-
"image_url": product.image_url,
|
| 69 |
-
"is_prime": product.is_prime,
|
| 70 |
-
"has_buy_box": product.has_buy_box if product.has_buy_box is not None else True,
|
| 71 |
-
"buy_box_winner": product.buy_box_winner,
|
| 72 |
-
"buy_box_price": float(product.buy_box_price) if product.buy_box_price else None,
|
| 73 |
-
"buy_box_is_fba": product.buy_box_is_fba,
|
| 74 |
-
"is_amazon_sold": product.is_amazon_sold or False,
|
| 75 |
-
"seller_count": product.seller_count or 1,
|
| 76 |
-
"current_price": float(latest.price) if latest and latest.price else None,
|
| 77 |
-
"current_bsr": latest.bsr if latest else None,
|
| 78 |
-
"current_rating": float(latest.rating) if latest and latest.rating else None,
|
| 79 |
-
"current_review_count": latest.review_count if latest else None,
|
| 80 |
-
"sales_estimate_monthly": sales_data["monthly_units"],
|
| 81 |
-
"revenue_estimate_monthly": round(sales_data["monthly_units"] * price_val, 2) if sales_data["monthly_units"] and price_val else None,
|
| 82 |
-
"data_source": product.last_data_source or "cached",
|
| 83 |
-
"last_synced_at": product.last_synced_at.isoformat() if product.last_synced_at else None,
|
| 84 |
-
"tracked_at": t.tracked_at.isoformat() if t.tracked_at else datetime.now(timezone.utc).isoformat(),
|
| 85 |
-
})
|
| 86 |
-
return result
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
@router.get("/{asin}")
|
| 90 |
-
def get_product(
|
| 91 |
-
asin: str,
|
| 92 |
-
request: Request,
|
| 93 |
-
refresh: bool = Query(False),
|
| 94 |
-
quick: bool = Query(False),
|
| 95 |
-
db: Session = Depends(get_db),
|
| 96 |
-
current_user: User = Depends(get_current_user),
|
| 97 |
-
):
|
| 98 |
-
asin = normalize_asin(asin)
|
| 99 |
-
if refresh:
|
| 100 |
-
enforce_scrape_rate_limit(str(current_user.id), request)
|
| 101 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 102 |
-
|
| 103 |
-
if quick:
|
| 104 |
-
if product:
|
| 105 |
-
from app.services.product_service import build_response
|
| 106 |
-
return build_response(db, product, product.last_data_source or "cached")
|
| 107 |
-
raise HTTPException(status_code=404, detail="Product not cached yet")
|
| 108 |
-
|
| 109 |
-
needs_scrape = refresh or not product
|
| 110 |
-
|
| 111 |
-
if not needs_scrape and product.last_synced_at:
|
| 112 |
-
last_synced = product.last_synced_at
|
| 113 |
-
if last_synced.tzinfo is None:
|
| 114 |
-
last_synced = last_synced.replace(tzinfo=timezone.utc)
|
| 115 |
-
age_hours = (datetime.now(timezone.utc) - last_synced).total_seconds() / 3600
|
| 116 |
-
needs_scrape = age_hours > 6
|
| 117 |
-
|
| 118 |
-
if needs_scrape:
|
| 119 |
-
enforce_search_limit(current_user, db)
|
| 120 |
-
|
| 121 |
-
result = fetch_and_sync_product(db, asin, force=refresh)
|
| 122 |
-
if not result:
|
| 123 |
-
raise HTTPException(status_code=404, detail="Product not found")
|
| 124 |
-
return result
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
@router.get("/{asin}/buy-box/history")
|
| 128 |
-
def get_buy_box_history(
|
| 129 |
-
asin: str,
|
| 130 |
-
days: int = Query(30, ge=7, le=365),
|
| 131 |
-
db: Session = Depends(get_db),
|
| 132 |
-
current_user: User = Depends(get_current_user),
|
| 133 |
-
):
|
| 134 |
-
asin = normalize_asin(asin)
|
| 135 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 136 |
-
if not product:
|
| 137 |
-
raise HTTPException(status_code=404, detail="Product not found")
|
| 138 |
-
|
| 139 |
-
latest = (
|
| 140 |
-
db.query(PriceHistory)
|
| 141 |
-
.filter(PriceHistory.product_id == product.id)
|
| 142 |
-
.order_by(PriceHistory.recorded_at.desc())
|
| 143 |
-
.limit(180)
|
| 144 |
-
.all()
|
| 145 |
-
)
|
| 146 |
-
price_history_rows = [
|
| 147 |
-
{
|
| 148 |
-
"price": float(h.price) if h.price else None,
|
| 149 |
-
"bsr": h.bsr,
|
| 150 |
-
"rating": float(h.rating) if h.rating else None,
|
| 151 |
-
"review_count": h.review_count,
|
| 152 |
-
"recorded_at": h.recorded_at.isoformat(),
|
| 153 |
-
}
|
| 154 |
-
for h in reversed(latest)
|
| 155 |
-
]
|
| 156 |
-
price = float(latest[0].price) if latest and latest[0].price else None
|
| 157 |
-
fallback = {
|
| 158 |
-
"buy_box_winner": product.buy_box_winner,
|
| 159 |
-
"buy_box_price": float(product.buy_box_price) if product.buy_box_price else price,
|
| 160 |
-
"buy_box_is_fba": product.buy_box_is_fba,
|
| 161 |
-
"is_amazon_sold": product.is_amazon_sold or False,
|
| 162 |
-
"seller_count": product.seller_count or 1,
|
| 163 |
-
"has_buy_box": product.has_buy_box if product.has_buy_box is not None else True,
|
| 164 |
-
"price": price,
|
| 165 |
-
"other_sellers": [],
|
| 166 |
-
"category": product.category or "default",
|
| 167 |
-
"price_history": price_history_rows,
|
| 168 |
-
}
|
| 169 |
-
return build_buy_box_history_payload(db, str(product.id), range_days=days, fallback_data=fallback)
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
@router.get("/{asin}/suppliers")
|
| 173 |
-
def get_product_suppliers(
|
| 174 |
-
asin: str,
|
| 175 |
-
db: Session = Depends(get_db),
|
| 176 |
-
current_user: User = Depends(get_current_user),
|
| 177 |
-
):
|
| 178 |
-
asin = normalize_asin(asin)
|
| 179 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 180 |
-
if not product:
|
| 181 |
-
raise HTTPException(status_code=404, detail="Product not found")
|
| 182 |
-
return find_suppliers(product.title or "", product.brand or "", asin)
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
@router.post("/{asin}/refresh")
|
| 186 |
-
def refresh_product(
|
| 187 |
-
asin: str,
|
| 188 |
-
request: Request,
|
| 189 |
-
db: Session = Depends(get_db),
|
| 190 |
-
current_user: User = Depends(get_current_user),
|
| 191 |
-
):
|
| 192 |
-
asin = normalize_asin(asin)
|
| 193 |
-
enforce_scrape_rate_limit(str(current_user.id), request)
|
| 194 |
-
enforce_search_limit(current_user, db)
|
| 195 |
-
result = fetch_and_sync_product(db, asin, force=True)
|
| 196 |
-
if not result:
|
| 197 |
-
raise HTTPException(status_code=404, detail="Product not found")
|
| 198 |
-
return result
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
@router.post("/{asin}/track")
|
| 202 |
-
def track_product(asin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 203 |
-
asin = normalize_asin(asin)
|
| 204 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 205 |
-
if not product:
|
| 206 |
-
raise HTTPException(status_code=404, detail="Fetch product first")
|
| 207 |
-
existing = db.query(TrackedProduct).filter(
|
| 208 |
-
TrackedProduct.user_id == str(current_user.id),
|
| 209 |
-
TrackedProduct.product_id == str(product.id),
|
| 210 |
-
).first()
|
| 211 |
-
if existing:
|
| 212 |
-
raise HTTPException(status_code=400, detail="Already tracking")
|
| 213 |
-
tracked = TrackedProduct(id=str(uuid.uuid4()), user_id=str(current_user.id), product_id=str(product.id))
|
| 214 |
-
db.add(tracked)
|
| 215 |
-
db.commit()
|
| 216 |
-
return {"message": "Product tracked!", "asin": asin}
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
@router.delete("/{asin}/track")
|
| 220 |
-
def untrack_product(asin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 221 |
-
asin = normalize_asin(asin)
|
| 222 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 223 |
-
if not product:
|
| 224 |
-
raise HTTPException(status_code=404, detail="Product not found")
|
| 225 |
-
tracked = db.query(TrackedProduct).filter(
|
| 226 |
-
TrackedProduct.user_id == str(current_user.id),
|
| 227 |
-
TrackedProduct.product_id == str(product.id),
|
| 228 |
-
).first()
|
| 229 |
-
if not tracked:
|
| 230 |
-
raise HTTPException(status_code=404, detail="Not tracking this product")
|
| 231 |
-
db.delete(tracked)
|
| 232 |
-
db.commit()
|
| 233 |
-
return {"message": "Untracked successfully"}
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
|
| 7 |
+
from app.database import get_db
|
| 8 |
+
from app.dependencies import get_current_user, enforce_search_limit
|
| 9 |
+
from app.models.user import User
|
| 10 |
+
from app.models.product import Product, PriceHistory, TrackedProduct
|
| 11 |
+
from app.services.product_service import fetch_and_sync_product, build_response
|
| 12 |
+
from app.services.analytics.buy_box_history import build_buy_box_history_payload
|
| 13 |
+
from app.services.amazon.product_scraper import search_asin_by_keyword
|
| 14 |
+
from app.services.amazon.keyword_service import get_amazon_suggestions
|
| 15 |
+
from app.services.supplier_service import find_suppliers
|
| 16 |
+
from app.utils.validators import normalize_asin
|
| 17 |
+
from app.security.input_guard import scan_user_text
|
| 18 |
+
from app.security.rate_limit import enforce_scrape_rate_limit
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/products", tags=["Products"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/search/text")
|
| 24 |
+
def search_by_keyword(
|
| 25 |
+
q: str = Query(..., min_length=2, max_length=120),
|
| 26 |
+
db: Session = Depends(get_db),
|
| 27 |
+
current_user: User = Depends(get_current_user),
|
| 28 |
+
):
|
| 29 |
+
"""Resolve a keyword to an ASIN via Amazon search, with autocomplete suggestions."""
|
| 30 |
+
enforce_search_limit(current_user, db)
|
| 31 |
+
q = scan_user_text(q.strip(), "search query")
|
| 32 |
+
asin = search_asin_by_keyword(q)
|
| 33 |
+
suggestions = get_amazon_suggestions(q)
|
| 34 |
+
if not asin and suggestions:
|
| 35 |
+
asin = search_asin_by_keyword(suggestions[0])
|
| 36 |
+
if not asin:
|
| 37 |
+
raise HTTPException(status_code=404, detail="No products found for that keyword")
|
| 38 |
+
asin = normalize_asin(asin)
|
| 39 |
+
result = fetch_and_sync_product(db, asin, force=True)
|
| 40 |
+
if not result:
|
| 41 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 42 |
+
result["matched_keyword"] = q
|
| 43 |
+
result["suggestions"] = suggestions[:8]
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/tracked/list")
|
| 48 |
+
def get_tracked(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 49 |
+
tracked_list = db.query(TrackedProduct).filter(TrackedProduct.user_id == str(current_user.id)).all()
|
| 50 |
+
result = []
|
| 51 |
+
for t in tracked_list:
|
| 52 |
+
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 53 |
+
if product:
|
| 54 |
+
latest = (
|
| 55 |
+
db.query(PriceHistory)
|
| 56 |
+
.filter(PriceHistory.product_id == product.id)
|
| 57 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 58 |
+
.first()
|
| 59 |
+
)
|
| 60 |
+
from app.services.amazon.sales_estimator import estimate_monthly_sales
|
| 61 |
+
sales_data = estimate_monthly_sales(latest.bsr if latest else 0, product.category or "")
|
| 62 |
+
price_val = float(latest.price) if latest and latest.price else None
|
| 63 |
+
result.append({
|
| 64 |
+
"asin": product.asin,
|
| 65 |
+
"title": product.title,
|
| 66 |
+
"brand": product.brand,
|
| 67 |
+
"upc": product.upc,
|
| 68 |
+
"image_url": product.image_url,
|
| 69 |
+
"is_prime": product.is_prime,
|
| 70 |
+
"has_buy_box": product.has_buy_box if product.has_buy_box is not None else True,
|
| 71 |
+
"buy_box_winner": product.buy_box_winner,
|
| 72 |
+
"buy_box_price": float(product.buy_box_price) if product.buy_box_price else None,
|
| 73 |
+
"buy_box_is_fba": product.buy_box_is_fba,
|
| 74 |
+
"is_amazon_sold": product.is_amazon_sold or False,
|
| 75 |
+
"seller_count": product.seller_count or 1,
|
| 76 |
+
"current_price": float(latest.price) if latest and latest.price else None,
|
| 77 |
+
"current_bsr": latest.bsr if latest else None,
|
| 78 |
+
"current_rating": float(latest.rating) if latest and latest.rating else None,
|
| 79 |
+
"current_review_count": latest.review_count if latest else None,
|
| 80 |
+
"sales_estimate_monthly": sales_data["monthly_units"],
|
| 81 |
+
"revenue_estimate_monthly": round(sales_data["monthly_units"] * price_val, 2) if sales_data["monthly_units"] and price_val else None,
|
| 82 |
+
"data_source": product.last_data_source or "cached",
|
| 83 |
+
"last_synced_at": product.last_synced_at.isoformat() if product.last_synced_at else None,
|
| 84 |
+
"tracked_at": t.tracked_at.isoformat() if t.tracked_at else datetime.now(timezone.utc).isoformat(),
|
| 85 |
+
})
|
| 86 |
+
return result
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@router.get("/{asin}")
|
| 90 |
+
def get_product(
|
| 91 |
+
asin: str,
|
| 92 |
+
request: Request,
|
| 93 |
+
refresh: bool = Query(False),
|
| 94 |
+
quick: bool = Query(False),
|
| 95 |
+
db: Session = Depends(get_db),
|
| 96 |
+
current_user: User = Depends(get_current_user),
|
| 97 |
+
):
|
| 98 |
+
asin = normalize_asin(asin)
|
| 99 |
+
if refresh:
|
| 100 |
+
enforce_scrape_rate_limit(str(current_user.id), request)
|
| 101 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 102 |
+
|
| 103 |
+
if quick:
|
| 104 |
+
if product:
|
| 105 |
+
from app.services.product_service import build_response
|
| 106 |
+
return build_response(db, product, product.last_data_source or "cached")
|
| 107 |
+
raise HTTPException(status_code=404, detail="Product not cached yet")
|
| 108 |
+
|
| 109 |
+
needs_scrape = refresh or not product
|
| 110 |
+
|
| 111 |
+
if not needs_scrape and product.last_synced_at:
|
| 112 |
+
last_synced = product.last_synced_at
|
| 113 |
+
if last_synced.tzinfo is None:
|
| 114 |
+
last_synced = last_synced.replace(tzinfo=timezone.utc)
|
| 115 |
+
age_hours = (datetime.now(timezone.utc) - last_synced).total_seconds() / 3600
|
| 116 |
+
needs_scrape = age_hours > 6
|
| 117 |
+
|
| 118 |
+
if needs_scrape:
|
| 119 |
+
enforce_search_limit(current_user, db)
|
| 120 |
+
|
| 121 |
+
result = fetch_and_sync_product(db, asin, force=refresh)
|
| 122 |
+
if not result:
|
| 123 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 124 |
+
return result
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@router.get("/{asin}/buy-box/history")
|
| 128 |
+
def get_buy_box_history(
|
| 129 |
+
asin: str,
|
| 130 |
+
days: int = Query(30, ge=7, le=365),
|
| 131 |
+
db: Session = Depends(get_db),
|
| 132 |
+
current_user: User = Depends(get_current_user),
|
| 133 |
+
):
|
| 134 |
+
asin = normalize_asin(asin)
|
| 135 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 136 |
+
if not product:
|
| 137 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 138 |
+
|
| 139 |
+
latest = (
|
| 140 |
+
db.query(PriceHistory)
|
| 141 |
+
.filter(PriceHistory.product_id == product.id)
|
| 142 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 143 |
+
.limit(180)
|
| 144 |
+
.all()
|
| 145 |
+
)
|
| 146 |
+
price_history_rows = [
|
| 147 |
+
{
|
| 148 |
+
"price": float(h.price) if h.price else None,
|
| 149 |
+
"bsr": h.bsr,
|
| 150 |
+
"rating": float(h.rating) if h.rating else None,
|
| 151 |
+
"review_count": h.review_count,
|
| 152 |
+
"recorded_at": h.recorded_at.isoformat(),
|
| 153 |
+
}
|
| 154 |
+
for h in reversed(latest)
|
| 155 |
+
]
|
| 156 |
+
price = float(latest[0].price) if latest and latest[0].price else None
|
| 157 |
+
fallback = {
|
| 158 |
+
"buy_box_winner": product.buy_box_winner,
|
| 159 |
+
"buy_box_price": float(product.buy_box_price) if product.buy_box_price else price,
|
| 160 |
+
"buy_box_is_fba": product.buy_box_is_fba,
|
| 161 |
+
"is_amazon_sold": product.is_amazon_sold or False,
|
| 162 |
+
"seller_count": product.seller_count or 1,
|
| 163 |
+
"has_buy_box": product.has_buy_box if product.has_buy_box is not None else True,
|
| 164 |
+
"price": price,
|
| 165 |
+
"other_sellers": [],
|
| 166 |
+
"category": product.category or "default",
|
| 167 |
+
"price_history": price_history_rows,
|
| 168 |
+
}
|
| 169 |
+
return build_buy_box_history_payload(db, str(product.id), range_days=days, fallback_data=fallback)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@router.get("/{asin}/suppliers")
|
| 173 |
+
def get_product_suppliers(
|
| 174 |
+
asin: str,
|
| 175 |
+
db: Session = Depends(get_db),
|
| 176 |
+
current_user: User = Depends(get_current_user),
|
| 177 |
+
):
|
| 178 |
+
asin = normalize_asin(asin)
|
| 179 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 180 |
+
if not product:
|
| 181 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 182 |
+
return find_suppliers(product.title or "", product.brand or "", asin)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@router.post("/{asin}/refresh")
|
| 186 |
+
def refresh_product(
|
| 187 |
+
asin: str,
|
| 188 |
+
request: Request,
|
| 189 |
+
db: Session = Depends(get_db),
|
| 190 |
+
current_user: User = Depends(get_current_user),
|
| 191 |
+
):
|
| 192 |
+
asin = normalize_asin(asin)
|
| 193 |
+
enforce_scrape_rate_limit(str(current_user.id), request)
|
| 194 |
+
enforce_search_limit(current_user, db)
|
| 195 |
+
result = fetch_and_sync_product(db, asin, force=True)
|
| 196 |
+
if not result:
|
| 197 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 198 |
+
return result
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@router.post("/{asin}/track")
|
| 202 |
+
def track_product(asin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 203 |
+
asin = normalize_asin(asin)
|
| 204 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 205 |
+
if not product:
|
| 206 |
+
raise HTTPException(status_code=404, detail="Fetch product first")
|
| 207 |
+
existing = db.query(TrackedProduct).filter(
|
| 208 |
+
TrackedProduct.user_id == str(current_user.id),
|
| 209 |
+
TrackedProduct.product_id == str(product.id),
|
| 210 |
+
).first()
|
| 211 |
+
if existing:
|
| 212 |
+
raise HTTPException(status_code=400, detail="Already tracking")
|
| 213 |
+
tracked = TrackedProduct(id=str(uuid.uuid4()), user_id=str(current_user.id), product_id=str(product.id))
|
| 214 |
+
db.add(tracked)
|
| 215 |
+
db.commit()
|
| 216 |
+
return {"message": "Product tracked!", "asin": asin}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@router.delete("/{asin}/track")
|
| 220 |
+
def untrack_product(asin: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
| 221 |
+
asin = normalize_asin(asin)
|
| 222 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 223 |
+
if not product:
|
| 224 |
+
raise HTTPException(status_code=404, detail="Product not found")
|
| 225 |
+
tracked = db.query(TrackedProduct).filter(
|
| 226 |
+
TrackedProduct.user_id == str(current_user.id),
|
| 227 |
+
TrackedProduct.product_id == str(product.id),
|
| 228 |
+
).first()
|
| 229 |
+
if not tracked:
|
| 230 |
+
raise HTTPException(status_code=404, detail="Not tracking this product")
|
| 231 |
+
db.delete(tracked)
|
| 232 |
+
db.commit()
|
| 233 |
+
return {"message": "Untracked successfully"}
|
app/routers/profit.py
CHANGED
|
@@ -1,176 +1,176 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, Query
|
| 2 |
-
from app.dependencies import get_current_user
|
| 3 |
-
from app.models.user import User
|
| 4 |
-
from app.services.analytics.profit_calculator import calculate_profit, compare_fba_fbm, calculate_storage_cost_per_unit
|
| 5 |
-
from sqlalchemy.orm import Session
|
| 6 |
-
from app.database import get_db
|
| 7 |
-
from app.models.product import Product, PriceHistory
|
| 8 |
-
|
| 9 |
-
router = APIRouter(prefix="/api/profit", tags=["Profit Calculator"])
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
@router.get("/calculate")
|
| 13 |
-
def profit_calculator(
|
| 14 |
-
selling_price: float = Query(...),
|
| 15 |
-
product_cost: float = Query(...),
|
| 16 |
-
category: str = Query(default="default"),
|
| 17 |
-
weight_lbs: float = Query(default=1.0),
|
| 18 |
-
shipping_to_fba: float = Query(default=0.56),
|
| 19 |
-
fulfillment_mode: str = Query(default="fba"),
|
| 20 |
-
fbm_fulfillment_cost: float = Query(default=0.0),
|
| 21 |
-
storage_monthly_per_unit: float = Query(default=0.0),
|
| 22 |
-
avg_inventory_units: float = Query(default=1.0),
|
| 23 |
-
monthly_units_sold: float = Query(default=1.0),
|
| 24 |
-
misc_cost: float = Query(default=0.0),
|
| 25 |
-
shipping_charge: float = Query(default=0.0),
|
| 26 |
-
estimated_units: int = Query(default=1),
|
| 27 |
-
current_user: User = Depends(get_current_user),
|
| 28 |
-
):
|
| 29 |
-
storage = calculate_storage_cost_per_unit(storage_monthly_per_unit, avg_inventory_units, monthly_units_sold)
|
| 30 |
-
mode = "fbm" if fulfillment_mode.lower() == "fbm" else "fba"
|
| 31 |
-
return calculate_profit(
|
| 32 |
-
selling_price=selling_price,
|
| 33 |
-
product_cost=product_cost,
|
| 34 |
-
category=category,
|
| 35 |
-
weight_lbs=weight_lbs,
|
| 36 |
-
shipping_to_fba=shipping_to_fba,
|
| 37 |
-
additional_costs=misc_cost,
|
| 38 |
-
fulfillment_mode=mode,
|
| 39 |
-
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 40 |
-
storage_cost_per_unit=storage,
|
| 41 |
-
shipping_charge=shipping_charge,
|
| 42 |
-
estimated_units=estimated_units,
|
| 43 |
-
)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
@router.get("/compare")
|
| 47 |
-
def profit_compare_fba_fbm(
|
| 48 |
-
selling_price: float = Query(...),
|
| 49 |
-
product_cost: float = Query(...),
|
| 50 |
-
category: str = Query(default="default"),
|
| 51 |
-
weight_lbs: float = Query(default=1.0),
|
| 52 |
-
shipping_to_fba: float = Query(default=0.0),
|
| 53 |
-
fbm_fulfillment_cost: float = Query(default=0.0),
|
| 54 |
-
storage_monthly_fba: float = Query(default=0.0),
|
| 55 |
-
storage_monthly_fbm: float = Query(default=0.0),
|
| 56 |
-
avg_inventory_units: float = Query(default=1.0),
|
| 57 |
-
monthly_units_sold: float = Query(default=1.0),
|
| 58 |
-
misc_cost: float = Query(default=0.0),
|
| 59 |
-
shipping_charge: float = Query(default=0.0),
|
| 60 |
-
estimated_units: int = Query(default=1),
|
| 61 |
-
season: str = Query(default="standard", description="standard=Jan-Sep, peak=Oct-Dec"),
|
| 62 |
-
inbound_region: str = Query(default="west"),
|
| 63 |
-
units_per_shipment: int = Query(default=1),
|
| 64 |
-
removal_units: int = Query(default=0),
|
| 65 |
-
disposal_units: int = Query(default=0),
|
| 66 |
-
current_user: User = Depends(get_current_user),
|
| 67 |
-
):
|
| 68 |
-
season_key = "peak" if season.lower() == "peak" else "standard"
|
| 69 |
-
storage_fba = calculate_storage_cost_per_unit(storage_monthly_fba, avg_inventory_units, monthly_units_sold)
|
| 70 |
-
storage_fbm = calculate_storage_cost_per_unit(storage_monthly_fbm, avg_inventory_units, monthly_units_sold)
|
| 71 |
-
return compare_fba_fbm(
|
| 72 |
-
selling_price=selling_price,
|
| 73 |
-
product_cost=product_cost,
|
| 74 |
-
category=category,
|
| 75 |
-
weight_lbs=weight_lbs,
|
| 76 |
-
shipping_to_fba=shipping_to_fba,
|
| 77 |
-
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 78 |
-
storage_cost_per_unit_fba=storage_fba,
|
| 79 |
-
storage_cost_per_unit_fbm=storage_fbm,
|
| 80 |
-
misc_cost=misc_cost,
|
| 81 |
-
shipping_charge=shipping_charge,
|
| 82 |
-
estimated_units=estimated_units,
|
| 83 |
-
season=season_key,
|
| 84 |
-
inbound_region=inbound_region,
|
| 85 |
-
avg_inventory_units=avg_inventory_units,
|
| 86 |
-
monthly_units_sold=monthly_units_sold,
|
| 87 |
-
units_per_shipment=units_per_shipment,
|
| 88 |
-
removal_units=removal_units,
|
| 89 |
-
disposal_units=disposal_units,
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
@router.get("/history/{asin}")
|
| 94 |
-
def profit_history(
|
| 95 |
-
asin: str,
|
| 96 |
-
product_cost: float = Query(default=0.0),
|
| 97 |
-
misc_cost: float = Query(default=0.0),
|
| 98 |
-
db: Session = Depends(get_db),
|
| 99 |
-
current_user: User = Depends(get_current_user),
|
| 100 |
-
):
|
| 101 |
-
from app.services.analytics.profit_calculator import build_profit_history_series
|
| 102 |
-
|
| 103 |
-
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 104 |
-
if not product:
|
| 105 |
-
return {"error": "Product not found"}
|
| 106 |
-
history = (
|
| 107 |
-
db.query(PriceHistory)
|
| 108 |
-
.filter(PriceHistory.product_id == product.id)
|
| 109 |
-
.order_by(PriceHistory.recorded_at.asc())
|
| 110 |
-
.limit(500)
|
| 111 |
-
.all()
|
| 112 |
-
)
|
| 113 |
-
rows = [
|
| 114 |
-
{
|
| 115 |
-
"price": float(h.price) if h.price else None,
|
| 116 |
-
"bsr": h.bsr,
|
| 117 |
-
"recorded_at": h.recorded_at.isoformat(),
|
| 118 |
-
}
|
| 119 |
-
for h in history
|
| 120 |
-
]
|
| 121 |
-
weight = float(product.package_weight_lbs) if product.package_weight_lbs else 1.0
|
| 122 |
-
return {
|
| 123 |
-
"asin": asin,
|
| 124 |
-
"series": build_profit_history_series(rows, product.category or "default", weight, product_cost, misc_cost),
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
@router.get("/{asin}")
|
| 129 |
-
def profit_for_product(
|
| 130 |
-
asin: str,
|
| 131 |
-
product_cost: float = Query(..., description="Your cost to source the product"),
|
| 132 |
-
selling_price: float | None = Query(default=None, description="Override selling price (editable)"),
|
| 133 |
-
weight_lbs: float = Query(default=1.0),
|
| 134 |
-
shipping_to_fba: float = Query(default=0.56),
|
| 135 |
-
fulfillment_mode: str = Query(default="fba"),
|
| 136 |
-
fbm_fulfillment_cost: float = Query(default=0.0),
|
| 137 |
-
misc_cost: float = Query(default=0.0),
|
| 138 |
-
shipping_charge: float = Query(default=0.0),
|
| 139 |
-
estimated_units: int = Query(default=1),
|
| 140 |
-
db: Session = Depends(get_db),
|
| 141 |
-
current_user: User = Depends(get_current_user),
|
| 142 |
-
):
|
| 143 |
-
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 144 |
-
if not product:
|
| 145 |
-
return {"error": "Product not found"}
|
| 146 |
-
|
| 147 |
-
latest = (
|
| 148 |
-
db.query(PriceHistory)
|
| 149 |
-
.filter(PriceHistory.product_id == product.id)
|
| 150 |
-
.order_by(PriceHistory.recorded_at.desc())
|
| 151 |
-
.first()
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
price = selling_price
|
| 155 |
-
if price is None:
|
| 156 |
-
if not latest or not latest.price:
|
| 157 |
-
return {"error": "No price data available"}
|
| 158 |
-
price = float(latest.price)
|
| 159 |
-
|
| 160 |
-
mode = "fbm" if fulfillment_mode.lower() == "fbm" else "fba"
|
| 161 |
-
result = calculate_profit(
|
| 162 |
-
selling_price=float(price),
|
| 163 |
-
product_cost=product_cost,
|
| 164 |
-
category=product.category or "default",
|
| 165 |
-
weight_lbs=weight_lbs,
|
| 166 |
-
shipping_to_fba=shipping_to_fba,
|
| 167 |
-
additional_costs=misc_cost,
|
| 168 |
-
fulfillment_mode=mode,
|
| 169 |
-
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 170 |
-
shipping_charge=shipping_charge,
|
| 171 |
-
estimated_units=estimated_units,
|
| 172 |
-
)
|
| 173 |
-
|
| 174 |
-
result["asin"] = asin
|
| 175 |
-
result["product_title"] = product.title
|
| 176 |
-
return result
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query
|
| 2 |
+
from app.dependencies import get_current_user
|
| 3 |
+
from app.models.user import User
|
| 4 |
+
from app.services.analytics.profit_calculator import calculate_profit, compare_fba_fbm, calculate_storage_cost_per_unit
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from app.database import get_db
|
| 7 |
+
from app.models.product import Product, PriceHistory
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/profit", tags=["Profit Calculator"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/calculate")
|
| 13 |
+
def profit_calculator(
|
| 14 |
+
selling_price: float = Query(...),
|
| 15 |
+
product_cost: float = Query(...),
|
| 16 |
+
category: str = Query(default="default"),
|
| 17 |
+
weight_lbs: float = Query(default=1.0),
|
| 18 |
+
shipping_to_fba: float = Query(default=0.56),
|
| 19 |
+
fulfillment_mode: str = Query(default="fba"),
|
| 20 |
+
fbm_fulfillment_cost: float = Query(default=0.0),
|
| 21 |
+
storage_monthly_per_unit: float = Query(default=0.0),
|
| 22 |
+
avg_inventory_units: float = Query(default=1.0),
|
| 23 |
+
monthly_units_sold: float = Query(default=1.0),
|
| 24 |
+
misc_cost: float = Query(default=0.0),
|
| 25 |
+
shipping_charge: float = Query(default=0.0),
|
| 26 |
+
estimated_units: int = Query(default=1),
|
| 27 |
+
current_user: User = Depends(get_current_user),
|
| 28 |
+
):
|
| 29 |
+
storage = calculate_storage_cost_per_unit(storage_monthly_per_unit, avg_inventory_units, monthly_units_sold)
|
| 30 |
+
mode = "fbm" if fulfillment_mode.lower() == "fbm" else "fba"
|
| 31 |
+
return calculate_profit(
|
| 32 |
+
selling_price=selling_price,
|
| 33 |
+
product_cost=product_cost,
|
| 34 |
+
category=category,
|
| 35 |
+
weight_lbs=weight_lbs,
|
| 36 |
+
shipping_to_fba=shipping_to_fba,
|
| 37 |
+
additional_costs=misc_cost,
|
| 38 |
+
fulfillment_mode=mode,
|
| 39 |
+
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 40 |
+
storage_cost_per_unit=storage,
|
| 41 |
+
shipping_charge=shipping_charge,
|
| 42 |
+
estimated_units=estimated_units,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("/compare")
|
| 47 |
+
def profit_compare_fba_fbm(
|
| 48 |
+
selling_price: float = Query(...),
|
| 49 |
+
product_cost: float = Query(...),
|
| 50 |
+
category: str = Query(default="default"),
|
| 51 |
+
weight_lbs: float = Query(default=1.0),
|
| 52 |
+
shipping_to_fba: float = Query(default=0.0),
|
| 53 |
+
fbm_fulfillment_cost: float = Query(default=0.0),
|
| 54 |
+
storage_monthly_fba: float = Query(default=0.0),
|
| 55 |
+
storage_monthly_fbm: float = Query(default=0.0),
|
| 56 |
+
avg_inventory_units: float = Query(default=1.0),
|
| 57 |
+
monthly_units_sold: float = Query(default=1.0),
|
| 58 |
+
misc_cost: float = Query(default=0.0),
|
| 59 |
+
shipping_charge: float = Query(default=0.0),
|
| 60 |
+
estimated_units: int = Query(default=1),
|
| 61 |
+
season: str = Query(default="standard", description="standard=Jan-Sep, peak=Oct-Dec"),
|
| 62 |
+
inbound_region: str = Query(default="west"),
|
| 63 |
+
units_per_shipment: int = Query(default=1),
|
| 64 |
+
removal_units: int = Query(default=0),
|
| 65 |
+
disposal_units: int = Query(default=0),
|
| 66 |
+
current_user: User = Depends(get_current_user),
|
| 67 |
+
):
|
| 68 |
+
season_key = "peak" if season.lower() == "peak" else "standard"
|
| 69 |
+
storage_fba = calculate_storage_cost_per_unit(storage_monthly_fba, avg_inventory_units, monthly_units_sold)
|
| 70 |
+
storage_fbm = calculate_storage_cost_per_unit(storage_monthly_fbm, avg_inventory_units, monthly_units_sold)
|
| 71 |
+
return compare_fba_fbm(
|
| 72 |
+
selling_price=selling_price,
|
| 73 |
+
product_cost=product_cost,
|
| 74 |
+
category=category,
|
| 75 |
+
weight_lbs=weight_lbs,
|
| 76 |
+
shipping_to_fba=shipping_to_fba,
|
| 77 |
+
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 78 |
+
storage_cost_per_unit_fba=storage_fba,
|
| 79 |
+
storage_cost_per_unit_fbm=storage_fbm,
|
| 80 |
+
misc_cost=misc_cost,
|
| 81 |
+
shipping_charge=shipping_charge,
|
| 82 |
+
estimated_units=estimated_units,
|
| 83 |
+
season=season_key,
|
| 84 |
+
inbound_region=inbound_region,
|
| 85 |
+
avg_inventory_units=avg_inventory_units,
|
| 86 |
+
monthly_units_sold=monthly_units_sold,
|
| 87 |
+
units_per_shipment=units_per_shipment,
|
| 88 |
+
removal_units=removal_units,
|
| 89 |
+
disposal_units=disposal_units,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.get("/history/{asin}")
|
| 94 |
+
def profit_history(
|
| 95 |
+
asin: str,
|
| 96 |
+
product_cost: float = Query(default=0.0),
|
| 97 |
+
misc_cost: float = Query(default=0.0),
|
| 98 |
+
db: Session = Depends(get_db),
|
| 99 |
+
current_user: User = Depends(get_current_user),
|
| 100 |
+
):
|
| 101 |
+
from app.services.analytics.profit_calculator import build_profit_history_series
|
| 102 |
+
|
| 103 |
+
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 104 |
+
if not product:
|
| 105 |
+
return {"error": "Product not found"}
|
| 106 |
+
history = (
|
| 107 |
+
db.query(PriceHistory)
|
| 108 |
+
.filter(PriceHistory.product_id == product.id)
|
| 109 |
+
.order_by(PriceHistory.recorded_at.asc())
|
| 110 |
+
.limit(500)
|
| 111 |
+
.all()
|
| 112 |
+
)
|
| 113 |
+
rows = [
|
| 114 |
+
{
|
| 115 |
+
"price": float(h.price) if h.price else None,
|
| 116 |
+
"bsr": h.bsr,
|
| 117 |
+
"recorded_at": h.recorded_at.isoformat(),
|
| 118 |
+
}
|
| 119 |
+
for h in history
|
| 120 |
+
]
|
| 121 |
+
weight = float(product.package_weight_lbs) if product.package_weight_lbs else 1.0
|
| 122 |
+
return {
|
| 123 |
+
"asin": asin,
|
| 124 |
+
"series": build_profit_history_series(rows, product.category or "default", weight, product_cost, misc_cost),
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.get("/{asin}")
|
| 129 |
+
def profit_for_product(
|
| 130 |
+
asin: str,
|
| 131 |
+
product_cost: float = Query(..., description="Your cost to source the product"),
|
| 132 |
+
selling_price: float | None = Query(default=None, description="Override selling price (editable)"),
|
| 133 |
+
weight_lbs: float = Query(default=1.0),
|
| 134 |
+
shipping_to_fba: float = Query(default=0.56),
|
| 135 |
+
fulfillment_mode: str = Query(default="fba"),
|
| 136 |
+
fbm_fulfillment_cost: float = Query(default=0.0),
|
| 137 |
+
misc_cost: float = Query(default=0.0),
|
| 138 |
+
shipping_charge: float = Query(default=0.0),
|
| 139 |
+
estimated_units: int = Query(default=1),
|
| 140 |
+
db: Session = Depends(get_db),
|
| 141 |
+
current_user: User = Depends(get_current_user),
|
| 142 |
+
):
|
| 143 |
+
product = db.query(Product).filter(Product.asin == asin.upper()).first()
|
| 144 |
+
if not product:
|
| 145 |
+
return {"error": "Product not found"}
|
| 146 |
+
|
| 147 |
+
latest = (
|
| 148 |
+
db.query(PriceHistory)
|
| 149 |
+
.filter(PriceHistory.product_id == product.id)
|
| 150 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 151 |
+
.first()
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
price = selling_price
|
| 155 |
+
if price is None:
|
| 156 |
+
if not latest or not latest.price:
|
| 157 |
+
return {"error": "No price data available"}
|
| 158 |
+
price = float(latest.price)
|
| 159 |
+
|
| 160 |
+
mode = "fbm" if fulfillment_mode.lower() == "fbm" else "fba"
|
| 161 |
+
result = calculate_profit(
|
| 162 |
+
selling_price=float(price),
|
| 163 |
+
product_cost=product_cost,
|
| 164 |
+
category=product.category or "default",
|
| 165 |
+
weight_lbs=weight_lbs,
|
| 166 |
+
shipping_to_fba=shipping_to_fba,
|
| 167 |
+
additional_costs=misc_cost,
|
| 168 |
+
fulfillment_mode=mode,
|
| 169 |
+
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 170 |
+
shipping_charge=shipping_charge,
|
| 171 |
+
estimated_units=estimated_units,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
result["asin"] = asin
|
| 175 |
+
result["product_title"] = product.title
|
| 176 |
+
return result
|
app/schemas/user.py
CHANGED
|
@@ -1,31 +1,31 @@
|
|
| 1 |
-
from pydantic import BaseModel, EmailStr, Field
|
| 2 |
-
from typing import Optional
|
| 3 |
-
from datetime import datetime
|
| 4 |
-
import uuid
|
| 5 |
-
|
| 6 |
-
class UserRegister(BaseModel):
|
| 7 |
-
email: EmailStr
|
| 8 |
-
password: str = Field(min_length=8, max_length=128)
|
| 9 |
-
full_name: Optional[str] = Field(default=None, max_length=120)
|
| 10 |
-
|
| 11 |
-
class UserLogin(BaseModel):
|
| 12 |
-
email: EmailStr
|
| 13 |
-
password: str = Field(min_length=1, max_length=128)
|
| 14 |
-
|
| 15 |
-
class UserResponse(BaseModel):
|
| 16 |
-
id: uuid.UUID
|
| 17 |
-
email: str
|
| 18 |
-
full_name: Optional[str]
|
| 19 |
-
plan: str
|
| 20 |
-
created_at: datetime
|
| 21 |
-
|
| 22 |
-
class Config:
|
| 23 |
-
from_attributes = True
|
| 24 |
-
|
| 25 |
-
class Token(BaseModel):
|
| 26 |
-
access_token: str
|
| 27 |
-
token_type: str
|
| 28 |
-
user: UserResponse
|
| 29 |
-
|
| 30 |
-
class LogoutResponse(BaseModel):
|
| 31 |
-
message: str
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
class UserRegister(BaseModel):
|
| 7 |
+
email: EmailStr
|
| 8 |
+
password: str = Field(min_length=8, max_length=128)
|
| 9 |
+
full_name: Optional[str] = Field(default=None, max_length=120)
|
| 10 |
+
|
| 11 |
+
class UserLogin(BaseModel):
|
| 12 |
+
email: EmailStr
|
| 13 |
+
password: str = Field(min_length=1, max_length=128)
|
| 14 |
+
|
| 15 |
+
class UserResponse(BaseModel):
|
| 16 |
+
id: uuid.UUID
|
| 17 |
+
email: str
|
| 18 |
+
full_name: Optional[str]
|
| 19 |
+
plan: str
|
| 20 |
+
created_at: datetime
|
| 21 |
+
|
| 22 |
+
class Config:
|
| 23 |
+
from_attributes = True
|
| 24 |
+
|
| 25 |
+
class Token(BaseModel):
|
| 26 |
+
access_token: str
|
| 27 |
+
token_type: str
|
| 28 |
+
user: UserResponse
|
| 29 |
+
|
| 30 |
+
class LogoutResponse(BaseModel):
|
| 31 |
+
message: str
|
app/security/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
"""Rankora security helpers — SSTI, ReDoS, injection, replay, and rate-limit guards."""
|
|
|
|
| 1 |
+
"""Rankora security helpers — SSTI, ReDoS, injection, replay, and rate-limit guards."""
|
app/security/input_guard.py
CHANGED
|
@@ -1,83 +1,83 @@
|
|
| 1 |
-
"""Input validation: SSTI, SQL/NoSQL injection probes, ReDoS-safe string handling."""
|
| 2 |
-
import re
|
| 3 |
-
from fastapi import HTTPException
|
| 4 |
-
|
| 5 |
-
# Max sizes — long-password DoS / payload abuse
|
| 6 |
-
MAX_PASSWORD_CHARS = 128
|
| 7 |
-
MAX_PASSWORD_BYTES = 72 # bcrypt limit
|
| 8 |
-
MAX_TEXT_FIELD = 8_000
|
| 9 |
-
MAX_EMAIL_LEN = 254
|
| 10 |
-
MAX_NAME_LEN = 120
|
| 11 |
-
MAX_QUERY_LEN = 500
|
| 12 |
-
|
| 13 |
-
# SSTI / template injection probes (Jinja, Twig, ERB, etc.)
|
| 14 |
-
_SSTI = re.compile(
|
| 15 |
-
r"(\{\{|\}\}|{%|%}|#\{|<%|<\?|\$\{|\[\[|\]\]|"
|
| 16 |
-
r"__class__|__mro__|__subclasses__|__globals__|"
|
| 17 |
-
r"config\.|request\.|self\.)",
|
| 18 |
-
re.IGNORECASE,
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
# Common SQL / NoSQL injection signatures in user text
|
| 22 |
-
_INJECTION = re.compile(
|
| 23 |
-
r"(\bUNION\b\s+\bSELECT\b|\bDROP\b\s+\bTABLE\b|\bINSERT\b\s+\bINTO\b|"
|
| 24 |
-
r"\bOR\b\s+['\"]?\d+['\"]?\s*=\s*['\"]?\d+|"
|
| 25 |
-
r"\$where|\$gt|\$ne|\$regex|\{\s*\"\$)",
|
| 26 |
-
re.IGNORECASE,
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
# Safe bounded patterns only
|
| 30 |
-
_ASIN = re.compile(r"^[A-Z0-9]{10}$")
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def assert_password_safe(password: str) -> None:
|
| 34 |
-
"""Block long-password DoS before bcrypt."""
|
| 35 |
-
if not password:
|
| 36 |
-
raise HTTPException(status_code=400, detail="Password is required")
|
| 37 |
-
if len(password) < 8:
|
| 38 |
-
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
| 39 |
-
if len(password) > MAX_PASSWORD_CHARS:
|
| 40 |
-
raise HTTPException(status_code=400, detail=f"Password must be at most {MAX_PASSWORD_CHARS} characters")
|
| 41 |
-
if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES:
|
| 42 |
-
raise HTTPException(status_code=400, detail="Password is too long for secure hashing")
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def clamp_str(value: str | None, max_len: int, field: str = "field") -> str:
|
| 46 |
-
if value is None:
|
| 47 |
-
return ""
|
| 48 |
-
if not isinstance(value, str):
|
| 49 |
-
raise HTTPException(status_code=400, detail=f"Invalid {field}")
|
| 50 |
-
if len(value) > max_len:
|
| 51 |
-
raise HTTPException(status_code=400, detail=f"{field} exceeds maximum length ({max_len})")
|
| 52 |
-
return value
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def scan_user_text(value: str, field: str = "input") -> str:
|
| 56 |
-
"""Reject SSTI and injection probe strings in free-text fields."""
|
| 57 |
-
value = clamp_str(value, MAX_TEXT_FIELD, field)
|
| 58 |
-
if _SSTI.search(value):
|
| 59 |
-
raise HTTPException(status_code=400, detail="Invalid characters in request (template injection blocked)")
|
| 60 |
-
if _INJECTION.search(value):
|
| 61 |
-
raise HTTPException(status_code=400, detail="Invalid characters in request (injection blocked)")
|
| 62 |
-
return value
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def strip_for_regex(text: str, max_len: int = 500) -> str:
|
| 66 |
-
"""ReDoS-safe prep: cap length before regex; strip without nested backtracking."""
|
| 67 |
-
if not text:
|
| 68 |
-
return ""
|
| 69 |
-
text = text[:max_len]
|
| 70 |
-
# Simple character removal instead of heavy regex on long strings
|
| 71 |
-
out = []
|
| 72 |
-
for ch in text:
|
| 73 |
-
if ch in "()[]":
|
| 74 |
-
continue
|
| 75 |
-
out.append(ch)
|
| 76 |
-
return "".join(out)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def normalize_asin_safe(asin: str) -> str:
|
| 80 |
-
cleaned = (asin or "").upper().strip()[:16]
|
| 81 |
-
if not _ASIN.match(cleaned):
|
| 82 |
-
raise HTTPException(status_code=400, detail="Invalid ASIN — must be 10 alphanumeric characters")
|
| 83 |
-
return cleaned
|
|
|
|
| 1 |
+
"""Input validation: SSTI, SQL/NoSQL injection probes, ReDoS-safe string handling."""
|
| 2 |
+
import re
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
|
| 5 |
+
# Max sizes — long-password DoS / payload abuse
|
| 6 |
+
MAX_PASSWORD_CHARS = 128
|
| 7 |
+
MAX_PASSWORD_BYTES = 72 # bcrypt limit
|
| 8 |
+
MAX_TEXT_FIELD = 8_000
|
| 9 |
+
MAX_EMAIL_LEN = 254
|
| 10 |
+
MAX_NAME_LEN = 120
|
| 11 |
+
MAX_QUERY_LEN = 500
|
| 12 |
+
|
| 13 |
+
# SSTI / template injection probes (Jinja, Twig, ERB, etc.)
|
| 14 |
+
_SSTI = re.compile(
|
| 15 |
+
r"(\{\{|\}\}|{%|%}|#\{|<%|<\?|\$\{|\[\[|\]\]|"
|
| 16 |
+
r"__class__|__mro__|__subclasses__|__globals__|"
|
| 17 |
+
r"config\.|request\.|self\.)",
|
| 18 |
+
re.IGNORECASE,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Common SQL / NoSQL injection signatures in user text
|
| 22 |
+
_INJECTION = re.compile(
|
| 23 |
+
r"(\bUNION\b\s+\bSELECT\b|\bDROP\b\s+\bTABLE\b|\bINSERT\b\s+\bINTO\b|"
|
| 24 |
+
r"\bOR\b\s+['\"]?\d+['\"]?\s*=\s*['\"]?\d+|"
|
| 25 |
+
r"\$where|\$gt|\$ne|\$regex|\{\s*\"\$)",
|
| 26 |
+
re.IGNORECASE,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Safe bounded patterns only
|
| 30 |
+
_ASIN = re.compile(r"^[A-Z0-9]{10}$")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def assert_password_safe(password: str) -> None:
|
| 34 |
+
"""Block long-password DoS before bcrypt."""
|
| 35 |
+
if not password:
|
| 36 |
+
raise HTTPException(status_code=400, detail="Password is required")
|
| 37 |
+
if len(password) < 8:
|
| 38 |
+
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
| 39 |
+
if len(password) > MAX_PASSWORD_CHARS:
|
| 40 |
+
raise HTTPException(status_code=400, detail=f"Password must be at most {MAX_PASSWORD_CHARS} characters")
|
| 41 |
+
if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES:
|
| 42 |
+
raise HTTPException(status_code=400, detail="Password is too long for secure hashing")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def clamp_str(value: str | None, max_len: int, field: str = "field") -> str:
|
| 46 |
+
if value is None:
|
| 47 |
+
return ""
|
| 48 |
+
if not isinstance(value, str):
|
| 49 |
+
raise HTTPException(status_code=400, detail=f"Invalid {field}")
|
| 50 |
+
if len(value) > max_len:
|
| 51 |
+
raise HTTPException(status_code=400, detail=f"{field} exceeds maximum length ({max_len})")
|
| 52 |
+
return value
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def scan_user_text(value: str, field: str = "input") -> str:
|
| 56 |
+
"""Reject SSTI and injection probe strings in free-text fields."""
|
| 57 |
+
value = clamp_str(value, MAX_TEXT_FIELD, field)
|
| 58 |
+
if _SSTI.search(value):
|
| 59 |
+
raise HTTPException(status_code=400, detail="Invalid characters in request (template injection blocked)")
|
| 60 |
+
if _INJECTION.search(value):
|
| 61 |
+
raise HTTPException(status_code=400, detail="Invalid characters in request (injection blocked)")
|
| 62 |
+
return value
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def strip_for_regex(text: str, max_len: int = 500) -> str:
|
| 66 |
+
"""ReDoS-safe prep: cap length before regex; strip without nested backtracking."""
|
| 67 |
+
if not text:
|
| 68 |
+
return ""
|
| 69 |
+
text = text[:max_len]
|
| 70 |
+
# Simple character removal instead of heavy regex on long strings
|
| 71 |
+
out = []
|
| 72 |
+
for ch in text:
|
| 73 |
+
if ch in "()[]":
|
| 74 |
+
continue
|
| 75 |
+
out.append(ch)
|
| 76 |
+
return "".join(out)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def normalize_asin_safe(asin: str) -> str:
|
| 80 |
+
cleaned = (asin or "").upper().strip()[:16]
|
| 81 |
+
if not _ASIN.match(cleaned):
|
| 82 |
+
raise HTTPException(status_code=400, detail="Invalid ASIN — must be 10 alphanumeric characters")
|
| 83 |
+
return cleaned
|
app/security/middleware.py
CHANGED
|
@@ -1,67 +1,67 @@
|
|
| 1 |
-
"""Security middleware: headers, body size, injection scan on JSON bodies."""
|
| 2 |
-
import json
|
| 3 |
-
from starlette.middleware.base import BaseHTTPMiddleware
|
| 4 |
-
from starlette.requests import Request
|
| 5 |
-
from starlette.responses import JSONResponse, Response
|
| 6 |
-
|
| 7 |
-
from app.security.input_guard import MAX_TEXT_FIELD, _INJECTION, _SSTI
|
| 8 |
-
|
| 9 |
-
MAX_BODY_BYTES = 512_000 # 512 KB
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
| 13 |
-
async def dispatch(self, request: Request, call_next) -> Response:
|
| 14 |
-
response = await call_next(request)
|
| 15 |
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 16 |
-
response.headers["X-Frame-Options"] = "DENY"
|
| 17 |
-
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 18 |
-
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
| 19 |
-
if request.url.path.startswith("/api/auth"):
|
| 20 |
-
response.headers["Cache-Control"] = "no-store"
|
| 21 |
-
return response
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
class RequestGuardMiddleware(BaseHTTPMiddleware):
|
| 25 |
-
"""Block oversized bodies and scan auth JSON for SSTI/injection probes."""
|
| 26 |
-
|
| 27 |
-
async def dispatch(self, request: Request, call_next) -> Response:
|
| 28 |
-
if request.method in ("POST", "PUT", "PATCH") and request.url.path.startswith("/api/auth"):
|
| 29 |
-
content_length = request.headers.get("content-length")
|
| 30 |
-
if content_length and int(content_length) > MAX_BODY_BYTES:
|
| 31 |
-
return JSONResponse(status_code=413, content={"detail": "Request body too large"})
|
| 32 |
-
|
| 33 |
-
ctype = (request.headers.get("content-type") or "").lower()
|
| 34 |
-
if "application/json" in ctype:
|
| 35 |
-
body = await request.body()
|
| 36 |
-
if len(body) > MAX_BODY_BYTES:
|
| 37 |
-
return JSONResponse(status_code=413, content={"detail": "Request body too large"})
|
| 38 |
-
if body:
|
| 39 |
-
try:
|
| 40 |
-
data = json.loads(body)
|
| 41 |
-
if _json_has_threats(data):
|
| 42 |
-
return JSONResponse(status_code=400, content={"detail": "Invalid request content blocked"})
|
| 43 |
-
except json.JSONDecodeError:
|
| 44 |
-
return JSONResponse(status_code=400, content={"detail": "Invalid JSON"})
|
| 45 |
-
|
| 46 |
-
async def receive():
|
| 47 |
-
return {"type": "http.request", "body": body, "more_body": False}
|
| 48 |
-
|
| 49 |
-
request = Request(request.scope, receive)
|
| 50 |
-
|
| 51 |
-
return await call_next(request)
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def _json_has_threats(obj, depth: int = 0) -> bool:
|
| 55 |
-
if depth > 12:
|
| 56 |
-
return True
|
| 57 |
-
if isinstance(obj, str):
|
| 58 |
-
if len(obj) > MAX_TEXT_FIELD:
|
| 59 |
-
return True
|
| 60 |
-
if _SSTI.search(obj) or _INJECTION.search(obj):
|
| 61 |
-
return True
|
| 62 |
-
return False
|
| 63 |
-
if isinstance(obj, dict):
|
| 64 |
-
return any(_json_has_threats(v, depth + 1) for v in obj.values())
|
| 65 |
-
if isinstance(obj, list):
|
| 66 |
-
return any(_json_has_threats(v, depth + 1) for v in obj[:200])
|
| 67 |
-
return False
|
|
|
|
| 1 |
+
"""Security middleware: headers, body size, injection scan on JSON bodies."""
|
| 2 |
+
import json
|
| 3 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 4 |
+
from starlette.requests import Request
|
| 5 |
+
from starlette.responses import JSONResponse, Response
|
| 6 |
+
|
| 7 |
+
from app.security.input_guard import MAX_TEXT_FIELD, _INJECTION, _SSTI
|
| 8 |
+
|
| 9 |
+
MAX_BODY_BYTES = 512_000 # 512 KB
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
| 13 |
+
async def dispatch(self, request: Request, call_next) -> Response:
|
| 14 |
+
response = await call_next(request)
|
| 15 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 16 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 17 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 18 |
+
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
| 19 |
+
if request.url.path.startswith("/api/auth"):
|
| 20 |
+
response.headers["Cache-Control"] = "no-store"
|
| 21 |
+
return response
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RequestGuardMiddleware(BaseHTTPMiddleware):
|
| 25 |
+
"""Block oversized bodies and scan auth JSON for SSTI/injection probes."""
|
| 26 |
+
|
| 27 |
+
async def dispatch(self, request: Request, call_next) -> Response:
|
| 28 |
+
if request.method in ("POST", "PUT", "PATCH") and request.url.path.startswith("/api/auth"):
|
| 29 |
+
content_length = request.headers.get("content-length")
|
| 30 |
+
if content_length and int(content_length) > MAX_BODY_BYTES:
|
| 31 |
+
return JSONResponse(status_code=413, content={"detail": "Request body too large"})
|
| 32 |
+
|
| 33 |
+
ctype = (request.headers.get("content-type") or "").lower()
|
| 34 |
+
if "application/json" in ctype:
|
| 35 |
+
body = await request.body()
|
| 36 |
+
if len(body) > MAX_BODY_BYTES:
|
| 37 |
+
return JSONResponse(status_code=413, content={"detail": "Request body too large"})
|
| 38 |
+
if body:
|
| 39 |
+
try:
|
| 40 |
+
data = json.loads(body)
|
| 41 |
+
if _json_has_threats(data):
|
| 42 |
+
return JSONResponse(status_code=400, content={"detail": "Invalid request content blocked"})
|
| 43 |
+
except json.JSONDecodeError:
|
| 44 |
+
return JSONResponse(status_code=400, content={"detail": "Invalid JSON"})
|
| 45 |
+
|
| 46 |
+
async def receive():
|
| 47 |
+
return {"type": "http.request", "body": body, "more_body": False}
|
| 48 |
+
|
| 49 |
+
request = Request(request.scope, receive)
|
| 50 |
+
|
| 51 |
+
return await call_next(request)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _json_has_threats(obj, depth: int = 0) -> bool:
|
| 55 |
+
if depth > 12:
|
| 56 |
+
return True
|
| 57 |
+
if isinstance(obj, str):
|
| 58 |
+
if len(obj) > MAX_TEXT_FIELD:
|
| 59 |
+
return True
|
| 60 |
+
if _SSTI.search(obj) or _INJECTION.search(obj):
|
| 61 |
+
return True
|
| 62 |
+
return False
|
| 63 |
+
if isinstance(obj, dict):
|
| 64 |
+
return any(_json_has_threats(v, depth + 1) for v in obj.values())
|
| 65 |
+
if isinstance(obj, list):
|
| 66 |
+
return any(_json_has_threats(v, depth + 1) for v in obj[:200])
|
| 67 |
+
return False
|
app/security/rate_limit.py
CHANGED
|
@@ -1,70 +1,70 @@
|
|
| 1 |
-
"""Rate limiting for auth and expensive API routes."""
|
| 2 |
-
import time
|
| 3 |
-
from collections import defaultdict
|
| 4 |
-
from threading import Lock
|
| 5 |
-
from fastapi import HTTPException, Request
|
| 6 |
-
|
| 7 |
-
_lock = Lock()
|
| 8 |
-
_buckets: dict[str, list[float]] = defaultdict(list)
|
| 9 |
-
|
| 10 |
-
AUTH_LIMIT = 10
|
| 11 |
-
AUTH_WINDOW_SEC = 900
|
| 12 |
-
|
| 13 |
-
AI_CHAT_LIMIT = 40
|
| 14 |
-
AI_CHAT_WINDOW_SEC = 3600
|
| 15 |
-
|
| 16 |
-
API_SCRAPE_LIMIT = 30
|
| 17 |
-
API_SCRAPE_WINDOW_SEC = 3600
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def _client_ip(request: Request) -> str:
|
| 21 |
-
forwarded = request.headers.get("x-forwarded-for")
|
| 22 |
-
if forwarded:
|
| 23 |
-
return forwarded.split(",")[0].strip()[:64]
|
| 24 |
-
if request.client:
|
| 25 |
-
return request.client.host or "unknown"
|
| 26 |
-
return "unknown"
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _client_ip_safe(request: Request) -> str:
|
| 30 |
-
try:
|
| 31 |
-
return _client_ip(request)
|
| 32 |
-
except Exception:
|
| 33 |
-
return "unknown"
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _enforce(key: str, limit: int, window_sec: int, message: str) -> None:
|
| 37 |
-
now = time.time()
|
| 38 |
-
with _lock:
|
| 39 |
-
hits = [t for t in _buckets[key] if now - t < window_sec]
|
| 40 |
-
if len(hits) >= limit:
|
| 41 |
-
raise HTTPException(status_code=429, detail=message)
|
| 42 |
-
hits.append(now)
|
| 43 |
-
_buckets[key] = hits
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def enforce_auth_rate_limit(request: Request, action: str = "auth") -> None:
|
| 47 |
-
_enforce(
|
| 48 |
-
f"{action}:{_client_ip_safe(request)}",
|
| 49 |
-
AUTH_LIMIT,
|
| 50 |
-
AUTH_WINDOW_SEC,
|
| 51 |
-
"Too many attempts. Please wait and try again.",
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def enforce_ai_rate_limit(user_id: str) -> None:
|
| 56 |
-
_enforce(
|
| 57 |
-
f"ai:{user_id}",
|
| 58 |
-
AI_CHAT_LIMIT,
|
| 59 |
-
AI_CHAT_WINDOW_SEC,
|
| 60 |
-
"AI chat rate limit reached. Please wait before sending more messages.",
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def enforce_scrape_rate_limit(user_id: str, request: Request) -> None:
|
| 65 |
-
_enforce(
|
| 66 |
-
f"scrape:{user_id}",
|
| 67 |
-
API_SCRAPE_LIMIT,
|
| 68 |
-
API_SCRAPE_WINDOW_SEC,
|
| 69 |
-
"Product refresh limit reached. Please wait before refreshing again.",
|
| 70 |
-
)
|
|
|
|
| 1 |
+
"""Rate limiting for auth and expensive API routes."""
|
| 2 |
+
import time
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from threading import Lock
|
| 5 |
+
from fastapi import HTTPException, Request
|
| 6 |
+
|
| 7 |
+
_lock = Lock()
|
| 8 |
+
_buckets: dict[str, list[float]] = defaultdict(list)
|
| 9 |
+
|
| 10 |
+
AUTH_LIMIT = 10
|
| 11 |
+
AUTH_WINDOW_SEC = 900
|
| 12 |
+
|
| 13 |
+
AI_CHAT_LIMIT = 40
|
| 14 |
+
AI_CHAT_WINDOW_SEC = 3600
|
| 15 |
+
|
| 16 |
+
API_SCRAPE_LIMIT = 30
|
| 17 |
+
API_SCRAPE_WINDOW_SEC = 3600
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _client_ip(request: Request) -> str:
|
| 21 |
+
forwarded = request.headers.get("x-forwarded-for")
|
| 22 |
+
if forwarded:
|
| 23 |
+
return forwarded.split(",")[0].strip()[:64]
|
| 24 |
+
if request.client:
|
| 25 |
+
return request.client.host or "unknown"
|
| 26 |
+
return "unknown"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _client_ip_safe(request: Request) -> str:
|
| 30 |
+
try:
|
| 31 |
+
return _client_ip(request)
|
| 32 |
+
except Exception:
|
| 33 |
+
return "unknown"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _enforce(key: str, limit: int, window_sec: int, message: str) -> None:
|
| 37 |
+
now = time.time()
|
| 38 |
+
with _lock:
|
| 39 |
+
hits = [t for t in _buckets[key] if now - t < window_sec]
|
| 40 |
+
if len(hits) >= limit:
|
| 41 |
+
raise HTTPException(status_code=429, detail=message)
|
| 42 |
+
hits.append(now)
|
| 43 |
+
_buckets[key] = hits
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def enforce_auth_rate_limit(request: Request, action: str = "auth") -> None:
|
| 47 |
+
_enforce(
|
| 48 |
+
f"{action}:{_client_ip_safe(request)}",
|
| 49 |
+
AUTH_LIMIT,
|
| 50 |
+
AUTH_WINDOW_SEC,
|
| 51 |
+
"Too many attempts. Please wait and try again.",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def enforce_ai_rate_limit(user_id: str) -> None:
|
| 56 |
+
_enforce(
|
| 57 |
+
f"ai:{user_id}",
|
| 58 |
+
AI_CHAT_LIMIT,
|
| 59 |
+
AI_CHAT_WINDOW_SEC,
|
| 60 |
+
"AI chat rate limit reached. Please wait before sending more messages.",
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def enforce_scrape_rate_limit(user_id: str, request: Request) -> None:
|
| 65 |
+
_enforce(
|
| 66 |
+
f"scrape:{user_id}",
|
| 67 |
+
API_SCRAPE_LIMIT,
|
| 68 |
+
API_SCRAPE_WINDOW_SEC,
|
| 69 |
+
"Product refresh limit reached. Please wait before refreshing again.",
|
| 70 |
+
)
|
app/security/secrets.py
CHANGED
|
@@ -1,44 +1,44 @@
|
|
| 1 |
-
"""Secret-key validation — prevent weak or leaked defaults in production."""
|
| 2 |
-
from app.config import settings
|
| 3 |
-
|
| 4 |
-
# Hard block — obvious placeholders only (app will not start)
|
| 5 |
-
_BLOCKED_KEYS = {
|
| 6 |
-
"change-this",
|
| 7 |
-
"secret",
|
| 8 |
-
"your-secret-key",
|
| 9 |
-
"dev-secret",
|
| 10 |
-
"changeme",
|
| 11 |
-
"password",
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
# Warn only — previously leaked or weak but may still be in HF secrets until user rotates
|
| 15 |
-
_WARN_KEYS = {
|
| 16 |
-
"rankora-super-secret-jwt-key-2026-shoaib",
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def validate_production_secrets() -> None:
|
| 21 |
-
key = (settings.secret_key or "").strip()
|
| 22 |
-
if not key:
|
| 23 |
-
raise RuntimeError("SECRET_KEY is required — set it in Hugging Face Space secrets")
|
| 24 |
-
if len(key) < 24:
|
| 25 |
-
raise RuntimeError("SECRET_KEY must be at least 24 characters")
|
| 26 |
-
low = key.lower()
|
| 27 |
-
if low in _BLOCKED_KEYS or "change-this" in low:
|
| 28 |
-
raise RuntimeError("SECRET_KEY is a default placeholder — set a unique random secret in HF Space settings")
|
| 29 |
-
if key in _WARN_KEYS or low in {k.lower() for k in _WARN_KEYS}:
|
| 30 |
-
print(
|
| 31 |
-
"⚠️ SECURITY WARNING: SECRET_KEY matches a known weak/leaked value. "
|
| 32 |
-
"Rotate it in HF Space → Settings → Secrets (use: openssl rand -hex 32)"
|
| 33 |
-
)
|
| 34 |
-
if settings.debug and settings.secret_key == "change-this":
|
| 35 |
-
print("⚠️ WARNING: Using default SECRET_KEY in debug mode only")
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def redact(value: str | None) -> str:
|
| 39 |
-
"""Redact secrets for logs/responses."""
|
| 40 |
-
if not value:
|
| 41 |
-
return ""
|
| 42 |
-
if len(value) <= 8:
|
| 43 |
-
return "***"
|
| 44 |
-
return value[:3] + "***" + value[-2:]
|
|
|
|
| 1 |
+
"""Secret-key validation — prevent weak or leaked defaults in production."""
|
| 2 |
+
from app.config import settings
|
| 3 |
+
|
| 4 |
+
# Hard block — obvious placeholders only (app will not start)
|
| 5 |
+
_BLOCKED_KEYS = {
|
| 6 |
+
"change-this",
|
| 7 |
+
"secret",
|
| 8 |
+
"your-secret-key",
|
| 9 |
+
"dev-secret",
|
| 10 |
+
"changeme",
|
| 11 |
+
"password",
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
# Warn only — previously leaked or weak but may still be in HF secrets until user rotates
|
| 15 |
+
_WARN_KEYS = {
|
| 16 |
+
"rankora-super-secret-jwt-key-2026-shoaib",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def validate_production_secrets() -> None:
|
| 21 |
+
key = (settings.secret_key or "").strip()
|
| 22 |
+
if not key:
|
| 23 |
+
raise RuntimeError("SECRET_KEY is required — set it in Hugging Face Space secrets")
|
| 24 |
+
if len(key) < 24:
|
| 25 |
+
raise RuntimeError("SECRET_KEY must be at least 24 characters")
|
| 26 |
+
low = key.lower()
|
| 27 |
+
if low in _BLOCKED_KEYS or "change-this" in low:
|
| 28 |
+
raise RuntimeError("SECRET_KEY is a default placeholder — set a unique random secret in HF Space settings")
|
| 29 |
+
if key in _WARN_KEYS or low in {k.lower() for k in _WARN_KEYS}:
|
| 30 |
+
print(
|
| 31 |
+
"⚠️ SECURITY WARNING: SECRET_KEY matches a known weak/leaked value. "
|
| 32 |
+
"Rotate it in HF Space → Settings → Secrets (use: openssl rand -hex 32)"
|
| 33 |
+
)
|
| 34 |
+
if settings.debug and settings.secret_key == "change-this":
|
| 35 |
+
print("⚠️ WARNING: Using default SECRET_KEY in debug mode only")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def redact(value: str | None) -> str:
|
| 39 |
+
"""Redact secrets for logs/responses."""
|
| 40 |
+
if not value:
|
| 41 |
+
return ""
|
| 42 |
+
if len(value) <= 8:
|
| 43 |
+
return "***"
|
| 44 |
+
return value[:3] + "***" + value[-2:]
|
app/security/token_revocation.py
CHANGED
|
@@ -1,38 +1,38 @@
|
|
| 1 |
-
"""JWT replay protection — jti denylist + per-user token version."""
|
| 2 |
-
import time
|
| 3 |
-
import uuid
|
| 4 |
-
from threading import Lock
|
| 5 |
-
|
| 6 |
-
_lock = Lock()
|
| 7 |
-
_revoked_jti: dict[str, float] = {} # jti -> expiry unix time
|
| 8 |
-
_TTL_BUFFER_SEC = 86400 * 2
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def new_jti() -> str:
|
| 12 |
-
return str(uuid.uuid4())
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def revoke_jti(jti: str, exp_unix: float | None = None) -> None:
|
| 16 |
-
if not jti:
|
| 17 |
-
return
|
| 18 |
-
until = exp_unix or (time.time() + _TTL_BUFFER_SEC)
|
| 19 |
-
with _lock:
|
| 20 |
-
_revoked_jti[jti] = until
|
| 21 |
-
_purge_expired_locked()
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def is_jti_revoked(jti: str | None) -> bool:
|
| 25 |
-
if not jti:
|
| 26 |
-
return False
|
| 27 |
-
now = time.time()
|
| 28 |
-
with _lock:
|
| 29 |
-
_purge_expired_locked()
|
| 30 |
-
until = _revoked_jti.get(jti)
|
| 31 |
-
return until is not None and until > now
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def _purge_expired_locked() -> None:
|
| 35 |
-
now = time.time()
|
| 36 |
-
expired = [k for k, v in _revoked_jti.items() if v <= now]
|
| 37 |
-
for k in expired:
|
| 38 |
-
del _revoked_jti[k]
|
|
|
|
| 1 |
+
"""JWT replay protection — jti denylist + per-user token version."""
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from threading import Lock
|
| 5 |
+
|
| 6 |
+
_lock = Lock()
|
| 7 |
+
_revoked_jti: dict[str, float] = {} # jti -> expiry unix time
|
| 8 |
+
_TTL_BUFFER_SEC = 86400 * 2
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def new_jti() -> str:
|
| 12 |
+
return str(uuid.uuid4())
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def revoke_jti(jti: str, exp_unix: float | None = None) -> None:
|
| 16 |
+
if not jti:
|
| 17 |
+
return
|
| 18 |
+
until = exp_unix or (time.time() + _TTL_BUFFER_SEC)
|
| 19 |
+
with _lock:
|
| 20 |
+
_revoked_jti[jti] = until
|
| 21 |
+
_purge_expired_locked()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def is_jti_revoked(jti: str | None) -> bool:
|
| 25 |
+
if not jti:
|
| 26 |
+
return False
|
| 27 |
+
now = time.time()
|
| 28 |
+
with _lock:
|
| 29 |
+
_purge_expired_locked()
|
| 30 |
+
until = _revoked_jti.get(jti)
|
| 31 |
+
return until is not None and until > now
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _purge_expired_locked() -> None:
|
| 35 |
+
now = time.time()
|
| 36 |
+
expired = [k for k, v in _revoked_jti.items() if v <= now]
|
| 37 |
+
for k in expired:
|
| 38 |
+
del _revoked_jti[k]
|
app/services/amazon/competitor_service.py
CHANGED
|
@@ -1,138 +1,138 @@
|
|
| 1 |
-
import requests
|
| 2 |
-
from bs4 import BeautifulSoup
|
| 3 |
-
from typing import List, Dict, Optional
|
| 4 |
-
from app.config import settings
|
| 5 |
-
import re
|
| 6 |
-
|
| 7 |
-
def scrape_category_bestsellers(category_url: str) -> List[Dict]:
|
| 8 |
-
"""Scrape Amazon bestseller list for a category"""
|
| 9 |
-
scraper_url = "https://api.scraperapi.com"
|
| 10 |
-
params = {
|
| 11 |
-
"api_key": settings.scraper_api_key,
|
| 12 |
-
"url": category_url,
|
| 13 |
-
"country_code": "us",
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
try:
|
| 17 |
-
response = requests.get(scraper_url, params=params, timeout=60)
|
| 18 |
-
if response.status_code != 200 or len(response.text) < 3000:
|
| 19 |
-
return []
|
| 20 |
-
|
| 21 |
-
soup = BeautifulSoup(response.text, "html.parser")
|
| 22 |
-
competitors = []
|
| 23 |
-
|
| 24 |
-
# Find product grid items
|
| 25 |
-
items = soup.find_all("div", {"class": re.compile(r"zg-grid-general-faceout|p13n-sc-uncoverable-faceout")})
|
| 26 |
-
|
| 27 |
-
for item in items[:10]:
|
| 28 |
-
try:
|
| 29 |
-
# Title
|
| 30 |
-
title_elem = item.find("span", {"class": re.compile(r"a-size-base|zg-text-center-align")})
|
| 31 |
-
title = title_elem.get_text(strip=True) if title_elem else None
|
| 32 |
-
|
| 33 |
-
# ASIN from link
|
| 34 |
-
link = item.find("a", href=True)
|
| 35 |
-
asin = None
|
| 36 |
-
if link:
|
| 37 |
-
match = re.search(r"/dp/([A-Z0-9]{10})", link["href"])
|
| 38 |
-
if match:
|
| 39 |
-
asin = match.group(1)
|
| 40 |
-
|
| 41 |
-
# Price
|
| 42 |
-
price = None
|
| 43 |
-
price_elem = item.find("span", {"class": re.compile(r"a-price|p13n-sc-price")})
|
| 44 |
-
if price_elem:
|
| 45 |
-
try:
|
| 46 |
-
price_text = price_elem.get_text(strip=True).replace("$", "").replace(",", "")
|
| 47 |
-
price = float(price_text.split()[0])
|
| 48 |
-
except:
|
| 49 |
-
pass
|
| 50 |
-
|
| 51 |
-
# Rating
|
| 52 |
-
rating = None
|
| 53 |
-
rating_elem = item.find("span", {"class": "a-icon-alt"})
|
| 54 |
-
if rating_elem:
|
| 55 |
-
try:
|
| 56 |
-
rating = float(rating_elem.get_text().split()[0])
|
| 57 |
-
except:
|
| 58 |
-
pass
|
| 59 |
-
|
| 60 |
-
if asin and title:
|
| 61 |
-
competitors.append({
|
| 62 |
-
"asin": asin,
|
| 63 |
-
"title": title[:100],
|
| 64 |
-
"price": price,
|
| 65 |
-
"rating": rating,
|
| 66 |
-
})
|
| 67 |
-
except:
|
| 68 |
-
continue
|
| 69 |
-
|
| 70 |
-
return competitors
|
| 71 |
-
except Exception as e:
|
| 72 |
-
print(f"Competitor scrape error: {e}")
|
| 73 |
-
return []
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def get_mock_competitors(asin: str, category: str) -> List[Dict]:
|
| 77 |
-
"""Return mock competitors when scraping fails"""
|
| 78 |
-
category_products = {
|
| 79 |
-
"books": [
|
| 80 |
-
{"asin": "B08CMF2CQF", "title": "Clean Code", "price": 35.99, "rating": 4.7, "bsr": 1200, "review_count": 8500},
|
| 81 |
-
{"asin": "B07X9RQ7PL", "title": "The Pragmatic Programmer", "price": 39.99, "rating": 4.6, "bsr": 1800, "review_count": 5200},
|
| 82 |
-
{"asin": "B00B77ER5O", "title": "Code Complete", "price": 42.00, "rating": 4.5, "bsr": 2100, "review_count": 3800},
|
| 83 |
-
{"asin": "B01NAEKSTD", "title": "Design Patterns", "price": 44.99, "rating": 4.4, "bsr": 3500, "review_count": 2900},
|
| 84 |
-
{"asin": "B07FPFL5SG", "title": "Refactoring", "price": 38.00, "rating": 4.5, "bsr": 4200, "review_count": 2100},
|
| 85 |
-
],
|
| 86 |
-
"electronics": [
|
| 87 |
-
{"asin": "B09B93ZDY4", "title": "Competitor Device A", "price": 49.99, "rating": 4.3, "bsr": 3000, "review_count": 12000},
|
| 88 |
-
{"asin": "B08F7N3T7X", "title": "Competitor Device B", "price": 39.99, "rating": 4.1, "bsr": 5500, "review_count": 8700},
|
| 89 |
-
{"asin": "B07YZB567G", "title": "Competitor Device C", "price": 59.99, "rating": 4.5, "bsr": 2200, "review_count": 15000},
|
| 90 |
-
],
|
| 91 |
-
"default": [
|
| 92 |
-
{"asin": "B08X1Y2Z3A", "title": "Similar Product A", "price": 24.99, "rating": 4.2, "bsr": 8000, "review_count": 3200},
|
| 93 |
-
{"asin": "B09A2B3C4D", "title": "Similar Product B", "price": 29.99, "rating": 4.4, "bsr": 6500, "review_count": 4800},
|
| 94 |
-
{"asin": "B07E5F6G7H", "title": "Similar Product C", "price": 19.99, "rating": 4.0, "bsr": 12000, "review_count": 1900},
|
| 95 |
-
{"asin": "B08H9I0J1K", "title": "Similar Product D", "price": 34.99, "rating": 4.6, "bsr": 4200, "review_count": 7100},
|
| 96 |
-
{"asin": "B09L2M3N4O", "title": "Similar Product E", "price": 22.99, "rating": 3.9, "bsr": 18000, "review_count": 890},
|
| 97 |
-
]
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
cat_lower = (category or "").lower()
|
| 101 |
-
products = category_products.get("default")
|
| 102 |
-
for key in category_products:
|
| 103 |
-
if key in cat_lower:
|
| 104 |
-
products = category_products[key]
|
| 105 |
-
break
|
| 106 |
-
|
| 107 |
-
# Add market share estimates
|
| 108 |
-
total_sales = sum(max(0, 100000 - p.get("bsr", 50000)) for p in products)
|
| 109 |
-
result = []
|
| 110 |
-
for p in products:
|
| 111 |
-
sales_estimate = max(0, 100000 - p.get("bsr", 50000))
|
| 112 |
-
market_share = round((sales_estimate / total_sales * 100) if total_sales > 0 else 0, 1)
|
| 113 |
-
result.append({**p, "market_share": market_share, "sales_estimate": sales_estimate // 1000})
|
| 114 |
-
|
| 115 |
-
return result
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def get_competitors_for_product(asin: str, category: str, title: str = "") -> tuple:
|
| 119 |
-
"""
|
| 120 |
-
Try live Amazon search scrape; fall back to category-based estimates.
|
| 121 |
-
Returns (competitors_list, data_source).
|
| 122 |
-
"""
|
| 123 |
-
keyword = (category or title or "product").split()[0:3]
|
| 124 |
-
seed = " ".join(keyword) if keyword else category or "bestseller"
|
| 125 |
-
|
| 126 |
-
if settings.scraper_api_key:
|
| 127 |
-
search_url = f"https://www.amazon.com/s?k={seed.replace(' ', '+')}"
|
| 128 |
-
live = scrape_category_bestsellers(search_url)
|
| 129 |
-
live = [c for c in live if c.get("asin") != asin][:8]
|
| 130 |
-
if live:
|
| 131 |
-
total = sum(max(1, 100000 - (c.get("bsr") or 50000)) for c in live) or 1
|
| 132 |
-
for c in live:
|
| 133 |
-
est = max(1, 100000 - (c.get("bsr") or 50000))
|
| 134 |
-
c["market_share"] = round(est / total * 100, 1)
|
| 135 |
-
c["sales_estimate"] = est // 1000
|
| 136 |
-
return live, "live"
|
| 137 |
-
|
| 138 |
return get_mock_competitors(asin, category), "estimated"
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from bs4 import BeautifulSoup
|
| 3 |
+
from typing import List, Dict, Optional
|
| 4 |
+
from app.config import settings
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
def scrape_category_bestsellers(category_url: str) -> List[Dict]:
|
| 8 |
+
"""Scrape Amazon bestseller list for a category"""
|
| 9 |
+
scraper_url = "https://api.scraperapi.com"
|
| 10 |
+
params = {
|
| 11 |
+
"api_key": settings.scraper_api_key,
|
| 12 |
+
"url": category_url,
|
| 13 |
+
"country_code": "us",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
response = requests.get(scraper_url, params=params, timeout=60)
|
| 18 |
+
if response.status_code != 200 or len(response.text) < 3000:
|
| 19 |
+
return []
|
| 20 |
+
|
| 21 |
+
soup = BeautifulSoup(response.text, "html.parser")
|
| 22 |
+
competitors = []
|
| 23 |
+
|
| 24 |
+
# Find product grid items
|
| 25 |
+
items = soup.find_all("div", {"class": re.compile(r"zg-grid-general-faceout|p13n-sc-uncoverable-faceout")})
|
| 26 |
+
|
| 27 |
+
for item in items[:10]:
|
| 28 |
+
try:
|
| 29 |
+
# Title
|
| 30 |
+
title_elem = item.find("span", {"class": re.compile(r"a-size-base|zg-text-center-align")})
|
| 31 |
+
title = title_elem.get_text(strip=True) if title_elem else None
|
| 32 |
+
|
| 33 |
+
# ASIN from link
|
| 34 |
+
link = item.find("a", href=True)
|
| 35 |
+
asin = None
|
| 36 |
+
if link:
|
| 37 |
+
match = re.search(r"/dp/([A-Z0-9]{10})", link["href"])
|
| 38 |
+
if match:
|
| 39 |
+
asin = match.group(1)
|
| 40 |
+
|
| 41 |
+
# Price
|
| 42 |
+
price = None
|
| 43 |
+
price_elem = item.find("span", {"class": re.compile(r"a-price|p13n-sc-price")})
|
| 44 |
+
if price_elem:
|
| 45 |
+
try:
|
| 46 |
+
price_text = price_elem.get_text(strip=True).replace("$", "").replace(",", "")
|
| 47 |
+
price = float(price_text.split()[0])
|
| 48 |
+
except:
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
# Rating
|
| 52 |
+
rating = None
|
| 53 |
+
rating_elem = item.find("span", {"class": "a-icon-alt"})
|
| 54 |
+
if rating_elem:
|
| 55 |
+
try:
|
| 56 |
+
rating = float(rating_elem.get_text().split()[0])
|
| 57 |
+
except:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
if asin and title:
|
| 61 |
+
competitors.append({
|
| 62 |
+
"asin": asin,
|
| 63 |
+
"title": title[:100],
|
| 64 |
+
"price": price,
|
| 65 |
+
"rating": rating,
|
| 66 |
+
})
|
| 67 |
+
except:
|
| 68 |
+
continue
|
| 69 |
+
|
| 70 |
+
return competitors
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"Competitor scrape error: {e}")
|
| 73 |
+
return []
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_mock_competitors(asin: str, category: str) -> List[Dict]:
|
| 77 |
+
"""Return mock competitors when scraping fails"""
|
| 78 |
+
category_products = {
|
| 79 |
+
"books": [
|
| 80 |
+
{"asin": "B08CMF2CQF", "title": "Clean Code", "price": 35.99, "rating": 4.7, "bsr": 1200, "review_count": 8500},
|
| 81 |
+
{"asin": "B07X9RQ7PL", "title": "The Pragmatic Programmer", "price": 39.99, "rating": 4.6, "bsr": 1800, "review_count": 5200},
|
| 82 |
+
{"asin": "B00B77ER5O", "title": "Code Complete", "price": 42.00, "rating": 4.5, "bsr": 2100, "review_count": 3800},
|
| 83 |
+
{"asin": "B01NAEKSTD", "title": "Design Patterns", "price": 44.99, "rating": 4.4, "bsr": 3500, "review_count": 2900},
|
| 84 |
+
{"asin": "B07FPFL5SG", "title": "Refactoring", "price": 38.00, "rating": 4.5, "bsr": 4200, "review_count": 2100},
|
| 85 |
+
],
|
| 86 |
+
"electronics": [
|
| 87 |
+
{"asin": "B09B93ZDY4", "title": "Competitor Device A", "price": 49.99, "rating": 4.3, "bsr": 3000, "review_count": 12000},
|
| 88 |
+
{"asin": "B08F7N3T7X", "title": "Competitor Device B", "price": 39.99, "rating": 4.1, "bsr": 5500, "review_count": 8700},
|
| 89 |
+
{"asin": "B07YZB567G", "title": "Competitor Device C", "price": 59.99, "rating": 4.5, "bsr": 2200, "review_count": 15000},
|
| 90 |
+
],
|
| 91 |
+
"default": [
|
| 92 |
+
{"asin": "B08X1Y2Z3A", "title": "Similar Product A", "price": 24.99, "rating": 4.2, "bsr": 8000, "review_count": 3200},
|
| 93 |
+
{"asin": "B09A2B3C4D", "title": "Similar Product B", "price": 29.99, "rating": 4.4, "bsr": 6500, "review_count": 4800},
|
| 94 |
+
{"asin": "B07E5F6G7H", "title": "Similar Product C", "price": 19.99, "rating": 4.0, "bsr": 12000, "review_count": 1900},
|
| 95 |
+
{"asin": "B08H9I0J1K", "title": "Similar Product D", "price": 34.99, "rating": 4.6, "bsr": 4200, "review_count": 7100},
|
| 96 |
+
{"asin": "B09L2M3N4O", "title": "Similar Product E", "price": 22.99, "rating": 3.9, "bsr": 18000, "review_count": 890},
|
| 97 |
+
]
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
cat_lower = (category or "").lower()
|
| 101 |
+
products = category_products.get("default")
|
| 102 |
+
for key in category_products:
|
| 103 |
+
if key in cat_lower:
|
| 104 |
+
products = category_products[key]
|
| 105 |
+
break
|
| 106 |
+
|
| 107 |
+
# Add market share estimates
|
| 108 |
+
total_sales = sum(max(0, 100000 - p.get("bsr", 50000)) for p in products)
|
| 109 |
+
result = []
|
| 110 |
+
for p in products:
|
| 111 |
+
sales_estimate = max(0, 100000 - p.get("bsr", 50000))
|
| 112 |
+
market_share = round((sales_estimate / total_sales * 100) if total_sales > 0 else 0, 1)
|
| 113 |
+
result.append({**p, "market_share": market_share, "sales_estimate": sales_estimate // 1000})
|
| 114 |
+
|
| 115 |
+
return result
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def get_competitors_for_product(asin: str, category: str, title: str = "") -> tuple:
|
| 119 |
+
"""
|
| 120 |
+
Try live Amazon search scrape; fall back to category-based estimates.
|
| 121 |
+
Returns (competitors_list, data_source).
|
| 122 |
+
"""
|
| 123 |
+
keyword = (category or title or "product").split()[0:3]
|
| 124 |
+
seed = " ".join(keyword) if keyword else category or "bestseller"
|
| 125 |
+
|
| 126 |
+
if settings.scraper_api_key:
|
| 127 |
+
search_url = f"https://www.amazon.com/s?k={seed.replace(' ', '+')}"
|
| 128 |
+
live = scrape_category_bestsellers(search_url)
|
| 129 |
+
live = [c for c in live if c.get("asin") != asin][:8]
|
| 130 |
+
if live:
|
| 131 |
+
total = sum(max(1, 100000 - (c.get("bsr") or 50000)) for c in live) or 1
|
| 132 |
+
for c in live:
|
| 133 |
+
est = max(1, 100000 - (c.get("bsr") or 50000))
|
| 134 |
+
c["market_share"] = round(est / total * 100, 1)
|
| 135 |
+
c["sales_estimate"] = est // 1000
|
| 136 |
+
return live, "live"
|
| 137 |
+
|
| 138 |
return get_mock_competitors(asin, category), "estimated"
|
app/services/amazon/keyword_service.py
CHANGED
|
@@ -1,132 +1,132 @@
|
|
| 1 |
-
import requests
|
| 2 |
-
from typing import List, Dict
|
| 3 |
-
import time
|
| 4 |
-
|
| 5 |
-
def get_amazon_suggestions(keyword: str) -> List[str]:
|
| 6 |
-
"""Get Amazon autocomplete suggestions"""
|
| 7 |
-
try:
|
| 8 |
-
url = "https://completion.amazon.com/api/2017/suggestions"
|
| 9 |
-
params = {
|
| 10 |
-
"word": keyword,
|
| 11 |
-
"marketplace": "US",
|
| 12 |
-
"session-id": "123-1234567-1234567",
|
| 13 |
-
"customer-id": "",
|
| 14 |
-
"request-id": "123456789",
|
| 15 |
-
"page-type": "Gateway",
|
| 16 |
-
"lop": "en_US",
|
| 17 |
-
"site-variant": "desktop",
|
| 18 |
-
"client-info": "amazon-search-ui",
|
| 19 |
-
"mid": "ATVPDKIKX0DER",
|
| 20 |
-
"alias": "aps",
|
| 21 |
-
"b2b": "0",
|
| 22 |
-
"fresh": "0",
|
| 23 |
-
"ks": "80",
|
| 24 |
-
"prefix": keyword,
|
| 25 |
-
"event": "onKeyPress",
|
| 26 |
-
"limit": "11",
|
| 27 |
-
"fb": "1",
|
| 28 |
-
}
|
| 29 |
-
headers = {
|
| 30 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 31 |
-
}
|
| 32 |
-
response = requests.get(url, params=params, headers=headers, timeout=10)
|
| 33 |
-
if response.status_code == 200:
|
| 34 |
-
data = response.json()
|
| 35 |
-
suggestions = []
|
| 36 |
-
for item in data.get("suggestions", []):
|
| 37 |
-
value = item.get("value", "")
|
| 38 |
-
if value and value != keyword:
|
| 39 |
-
suggestions.append(value)
|
| 40 |
-
return suggestions[:10]
|
| 41 |
-
except Exception as e:
|
| 42 |
-
print(f"Suggestion error: {e}")
|
| 43 |
-
return []
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def estimate_search_volume(keyword: str) -> Dict:
|
| 47 |
-
"""Estimate search volume based on keyword characteristics"""
|
| 48 |
-
word_count = len(keyword.split())
|
| 49 |
-
char_count = len(keyword)
|
| 50 |
-
|
| 51 |
-
# Heuristic scoring
|
| 52 |
-
base_volume = 50000
|
| 53 |
-
|
| 54 |
-
# Shorter keywords = higher volume
|
| 55 |
-
if word_count == 1:
|
| 56 |
-
volume = base_volume
|
| 57 |
-
elif word_count == 2:
|
| 58 |
-
volume = int(base_volume * 0.4)
|
| 59 |
-
elif word_count == 3:
|
| 60 |
-
volume = int(base_volume * 0.15)
|
| 61 |
-
else:
|
| 62 |
-
volume = int(base_volume * 0.05)
|
| 63 |
-
|
| 64 |
-
# Competition estimate
|
| 65 |
-
if word_count <= 2:
|
| 66 |
-
competition = "high"
|
| 67 |
-
competition_score = 0.8
|
| 68 |
-
elif word_count == 3:
|
| 69 |
-
competition = "medium"
|
| 70 |
-
competition_score = 0.5
|
| 71 |
-
else:
|
| 72 |
-
competition = "low"
|
| 73 |
-
competition_score = 0.2
|
| 74 |
-
|
| 75 |
-
is_long_tail = word_count >= 3
|
| 76 |
-
|
| 77 |
-
return {
|
| 78 |
-
"keyword": keyword,
|
| 79 |
-
"search_volume_estimate": volume,
|
| 80 |
-
"competition": competition,
|
| 81 |
-
"competition_score": competition_score,
|
| 82 |
-
"is_long_tail": is_long_tail,
|
| 83 |
-
"word_count": word_count,
|
| 84 |
-
"opportunity_score": round((1 - competition_score) * (volume / base_volume) * 100, 1)
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def get_keywords_for_product(title: str, asin: str) -> List[Dict]:
|
| 89 |
-
"""Generate keywords from product title + suggestions"""
|
| 90 |
-
if not title:
|
| 91 |
-
return []
|
| 92 |
-
|
| 93 |
-
# Extract seed keywords from title
|
| 94 |
-
stop_words = {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
|
| 95 |
-
"for", "of", "with", "by", "from", "edition", "version", "2nd",
|
| 96 |
-
"new", "best", "top", "great", "good", "perfect"}
|
| 97 |
-
|
| 98 |
-
words = title.lower().split()
|
| 99 |
-
seed_keywords = []
|
| 100 |
-
|
| 101 |
-
# Single important words
|
| 102 |
-
for word in words:
|
| 103 |
-
clean = word.strip(".,!?()[]")
|
| 104 |
-
if len(clean) > 3 and clean not in stop_words:
|
| 105 |
-
seed_keywords.append(clean)
|
| 106 |
-
|
| 107 |
-
# 2-word combinations
|
| 108 |
-
for i in range(len(words) - 1):
|
| 109 |
-
w1 = words[i].strip(".,!?()[]")
|
| 110 |
-
w2 = words[i+1].strip(".,!?()[]")
|
| 111 |
-
if w1 not in stop_words and w2 not in stop_words and len(w1) > 2 and len(w2) > 2:
|
| 112 |
-
seed_keywords.append(f"{w1} {w2}")
|
| 113 |
-
|
| 114 |
-
# Get suggestions for top seeds
|
| 115 |
-
all_keywords = list(set(seed_keywords[:5]))
|
| 116 |
-
|
| 117 |
-
for seed in seed_keywords[:3]:
|
| 118 |
-
suggestions = get_amazon_suggestions(seed)
|
| 119 |
-
all_keywords.extend(suggestions)
|
| 120 |
-
time.sleep(0.3)
|
| 121 |
-
|
| 122 |
-
# Score all keywords
|
| 123 |
-
scored = []
|
| 124 |
-
seen = set()
|
| 125 |
-
for kw in all_keywords:
|
| 126 |
-
if kw not in seen and len(kw) > 2:
|
| 127 |
-
seen.add(kw)
|
| 128 |
-
scored.append(estimate_search_volume(kw))
|
| 129 |
-
|
| 130 |
-
# Sort by opportunity score
|
| 131 |
-
scored.sort(key=lambda x: x["opportunity_score"], reverse=True)
|
| 132 |
return scored[:20]
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from typing import List, Dict
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
def get_amazon_suggestions(keyword: str) -> List[str]:
|
| 6 |
+
"""Get Amazon autocomplete suggestions"""
|
| 7 |
+
try:
|
| 8 |
+
url = "https://completion.amazon.com/api/2017/suggestions"
|
| 9 |
+
params = {
|
| 10 |
+
"word": keyword,
|
| 11 |
+
"marketplace": "US",
|
| 12 |
+
"session-id": "123-1234567-1234567",
|
| 13 |
+
"customer-id": "",
|
| 14 |
+
"request-id": "123456789",
|
| 15 |
+
"page-type": "Gateway",
|
| 16 |
+
"lop": "en_US",
|
| 17 |
+
"site-variant": "desktop",
|
| 18 |
+
"client-info": "amazon-search-ui",
|
| 19 |
+
"mid": "ATVPDKIKX0DER",
|
| 20 |
+
"alias": "aps",
|
| 21 |
+
"b2b": "0",
|
| 22 |
+
"fresh": "0",
|
| 23 |
+
"ks": "80",
|
| 24 |
+
"prefix": keyword,
|
| 25 |
+
"event": "onKeyPress",
|
| 26 |
+
"limit": "11",
|
| 27 |
+
"fb": "1",
|
| 28 |
+
}
|
| 29 |
+
headers = {
|
| 30 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 31 |
+
}
|
| 32 |
+
response = requests.get(url, params=params, headers=headers, timeout=10)
|
| 33 |
+
if response.status_code == 200:
|
| 34 |
+
data = response.json()
|
| 35 |
+
suggestions = []
|
| 36 |
+
for item in data.get("suggestions", []):
|
| 37 |
+
value = item.get("value", "")
|
| 38 |
+
if value and value != keyword:
|
| 39 |
+
suggestions.append(value)
|
| 40 |
+
return suggestions[:10]
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"Suggestion error: {e}")
|
| 43 |
+
return []
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def estimate_search_volume(keyword: str) -> Dict:
|
| 47 |
+
"""Estimate search volume based on keyword characteristics"""
|
| 48 |
+
word_count = len(keyword.split())
|
| 49 |
+
char_count = len(keyword)
|
| 50 |
+
|
| 51 |
+
# Heuristic scoring
|
| 52 |
+
base_volume = 50000
|
| 53 |
+
|
| 54 |
+
# Shorter keywords = higher volume
|
| 55 |
+
if word_count == 1:
|
| 56 |
+
volume = base_volume
|
| 57 |
+
elif word_count == 2:
|
| 58 |
+
volume = int(base_volume * 0.4)
|
| 59 |
+
elif word_count == 3:
|
| 60 |
+
volume = int(base_volume * 0.15)
|
| 61 |
+
else:
|
| 62 |
+
volume = int(base_volume * 0.05)
|
| 63 |
+
|
| 64 |
+
# Competition estimate
|
| 65 |
+
if word_count <= 2:
|
| 66 |
+
competition = "high"
|
| 67 |
+
competition_score = 0.8
|
| 68 |
+
elif word_count == 3:
|
| 69 |
+
competition = "medium"
|
| 70 |
+
competition_score = 0.5
|
| 71 |
+
else:
|
| 72 |
+
competition = "low"
|
| 73 |
+
competition_score = 0.2
|
| 74 |
+
|
| 75 |
+
is_long_tail = word_count >= 3
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
"keyword": keyword,
|
| 79 |
+
"search_volume_estimate": volume,
|
| 80 |
+
"competition": competition,
|
| 81 |
+
"competition_score": competition_score,
|
| 82 |
+
"is_long_tail": is_long_tail,
|
| 83 |
+
"word_count": word_count,
|
| 84 |
+
"opportunity_score": round((1 - competition_score) * (volume / base_volume) * 100, 1)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def get_keywords_for_product(title: str, asin: str) -> List[Dict]:
|
| 89 |
+
"""Generate keywords from product title + suggestions"""
|
| 90 |
+
if not title:
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
# Extract seed keywords from title
|
| 94 |
+
stop_words = {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
|
| 95 |
+
"for", "of", "with", "by", "from", "edition", "version", "2nd",
|
| 96 |
+
"new", "best", "top", "great", "good", "perfect"}
|
| 97 |
+
|
| 98 |
+
words = title.lower().split()
|
| 99 |
+
seed_keywords = []
|
| 100 |
+
|
| 101 |
+
# Single important words
|
| 102 |
+
for word in words:
|
| 103 |
+
clean = word.strip(".,!?()[]")
|
| 104 |
+
if len(clean) > 3 and clean not in stop_words:
|
| 105 |
+
seed_keywords.append(clean)
|
| 106 |
+
|
| 107 |
+
# 2-word combinations
|
| 108 |
+
for i in range(len(words) - 1):
|
| 109 |
+
w1 = words[i].strip(".,!?()[]")
|
| 110 |
+
w2 = words[i+1].strip(".,!?()[]")
|
| 111 |
+
if w1 not in stop_words and w2 not in stop_words and len(w1) > 2 and len(w2) > 2:
|
| 112 |
+
seed_keywords.append(f"{w1} {w2}")
|
| 113 |
+
|
| 114 |
+
# Get suggestions for top seeds
|
| 115 |
+
all_keywords = list(set(seed_keywords[:5]))
|
| 116 |
+
|
| 117 |
+
for seed in seed_keywords[:3]:
|
| 118 |
+
suggestions = get_amazon_suggestions(seed)
|
| 119 |
+
all_keywords.extend(suggestions)
|
| 120 |
+
time.sleep(0.3)
|
| 121 |
+
|
| 122 |
+
# Score all keywords
|
| 123 |
+
scored = []
|
| 124 |
+
seen = set()
|
| 125 |
+
for kw in all_keywords:
|
| 126 |
+
if kw not in seen and len(kw) > 2:
|
| 127 |
+
seen.add(kw)
|
| 128 |
+
scored.append(estimate_search_volume(kw))
|
| 129 |
+
|
| 130 |
+
# Sort by opportunity score
|
| 131 |
+
scored.sort(key=lambda x: x["opportunity_score"], reverse=True)
|
| 132 |
return scored[:20]
|
app/services/amazon/offers_scraper.py
CHANGED
|
@@ -1,158 +1,204 @@
|
|
| 1 |
-
"""Scrape live Amazon offer listing / AOD data for real seller competition."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import re
|
| 5 |
-
from typing import Dict, List, Optional, Tuple
|
| 6 |
-
|
| 7 |
-
import requests
|
| 8 |
-
from bs4 import BeautifulSoup
|
| 9 |
-
|
| 10 |
-
from app.
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
if
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
"
|
| 61 |
-
|
| 62 |
-
"
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scrape live Amazon offer listing / AOD data for real seller competition."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from typing import Dict, List, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
from bs4 import BeautifulSoup
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.services.amazon.scraper_utils import get_headers, normalize_price, parse_number
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _fetch_offers_html(url: str, timeout: int = 30) -> Optional[str]:
|
| 15 |
+
"""Fetch offer-listing HTML — ScraperAPI on cloud, direct fetch as fallback."""
|
| 16 |
+
if settings.scraper_api_key:
|
| 17 |
+
try:
|
| 18 |
+
resp = requests.get(
|
| 19 |
+
"https://api.scraperapi.com",
|
| 20 |
+
params={
|
| 21 |
+
"api_key": settings.scraper_api_key,
|
| 22 |
+
"url": url,
|
| 23 |
+
"country_code": "us",
|
| 24 |
+
},
|
| 25 |
+
timeout=timeout,
|
| 26 |
+
)
|
| 27 |
+
if resp.status_code == 200 and len(resp.text) > 200:
|
| 28 |
+
return resp.text
|
| 29 |
+
print(f"[offers_scraper] ScraperAPI HTTP {resp.status_code}")
|
| 30 |
+
except Exception as e:
|
| 31 |
+
print(f"[offers_scraper] ScraperAPI error: {e}")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
resp = requests.get(url, headers=get_headers(), timeout=timeout)
|
| 35 |
+
if resp.status_code == 200 and len(resp.text) > 200:
|
| 36 |
+
return resp.text
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"[offers_scraper] Direct fetch error: {e}")
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _parse_seller_rating(text: str) -> Optional[str]:
|
| 43 |
+
m = re.search(r"(\d{1,3})%\s*positive", text, re.I)
|
| 44 |
+
if m:
|
| 45 |
+
return f"{m.group(1)}%"
|
| 46 |
+
m = re.search(r"(\d{1,3})\s*%\s*ratings?", text, re.I)
|
| 47 |
+
if m:
|
| 48 |
+
return f"{m.group(1)}%"
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _parse_offer_row(offer_el) -> Optional[dict]:
|
| 53 |
+
"""Parse a single offer block from AOD or offer-listing HTML."""
|
| 54 |
+
text = offer_el.get_text(" ", strip=True)
|
| 55 |
+
if not text or len(text) < 3:
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
name = None
|
| 59 |
+
for sel in (
|
| 60 |
+
offer_el.select_one("#aod-offer-soldBy a"),
|
| 61 |
+
offer_el.select_one(".a-size-small.a-color-base"),
|
| 62 |
+
offer_el.select_one(".olpSellerName"),
|
| 63 |
+
offer_el.select_one("h3.olpSellerName"),
|
| 64 |
+
offer_el.find("a", href=re.compile(r"seller=")),
|
| 65 |
+
):
|
| 66 |
+
if sel:
|
| 67 |
+
name = sel.get_text(strip=True)
|
| 68 |
+
if name and len(name) > 1 and name.lower() not in ("amazon.com", "new"):
|
| 69 |
+
break
|
| 70 |
+
|
| 71 |
+
if not name:
|
| 72 |
+
sold_match = re.search(r"(?:Sold by|from)\s+([^·|]+)", text, re.I)
|
| 73 |
+
if sold_match:
|
| 74 |
+
name = sold_match.group(1).strip()[:80]
|
| 75 |
+
|
| 76 |
+
price = None
|
| 77 |
+
for price_el in offer_el.select(
|
| 78 |
+
".a-price .a-offscreen, .a-color-price, .olpOfferPrice, .a-price-whole"
|
| 79 |
+
):
|
| 80 |
+
price = parse_number(price_el.get_text())
|
| 81 |
+
if price:
|
| 82 |
+
break
|
| 83 |
+
if price is None:
|
| 84 |
+
pm = re.search(r"\$\s*([\d,]+\.?\d*)", text)
|
| 85 |
+
if pm:
|
| 86 |
+
price = parse_number(pm.group(1))
|
| 87 |
+
|
| 88 |
+
is_fba = bool(
|
| 89 |
+
offer_el.find(string=re.compile(r"Fulfilled by Amazon|FBA", re.I))
|
| 90 |
+
or offer_el.select_one(".a-icon-prime")
|
| 91 |
+
or ("Prime" in text and "Fulfilled" in text)
|
| 92 |
+
)
|
| 93 |
+
is_fbm = bool(offer_el.find(string=re.compile(r"Ships from.*Sold by", re.I))) and not is_fba
|
| 94 |
+
|
| 95 |
+
if not name:
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"name": name[:80],
|
| 100 |
+
"price": normalize_price(price) if price else None,
|
| 101 |
+
"is_fba": True if is_fba else (False if is_fbm else None),
|
| 102 |
+
"is_prime": bool(offer_el.select_one(".a-icon-prime")),
|
| 103 |
+
"rating": _parse_seller_rating(text),
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def scrape_offers(asin: str, timeout: int = 30) -> Tuple[List[dict], int, int, int]:
|
| 108 |
+
"""
|
| 109 |
+
Fetch real competing offers for an ASIN.
|
| 110 |
+
Returns: (offers, seller_count, fba_count, fbm_count)
|
| 111 |
+
"""
|
| 112 |
+
asin = asin.upper().strip()
|
| 113 |
+
offers: List[dict] = []
|
| 114 |
+
seen_names: set = set()
|
| 115 |
+
|
| 116 |
+
urls = [
|
| 117 |
+
f"https://www.amazon.com/gp/aod/ajax/ref=auto_load_aod?asin={asin}",
|
| 118 |
+
f"https://www.amazon.com/gp/product/ajax/aodAjaxMain/ref=auto_load_aod?asin={asin}",
|
| 119 |
+
f"https://www.amazon.com/gp/offer-listing/{asin}/ref=olp_tab_all",
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
for url in urls:
|
| 123 |
+
html = _fetch_offers_html(url, timeout=timeout)
|
| 124 |
+
if not html:
|
| 125 |
+
continue
|
| 126 |
+
try:
|
| 127 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 128 |
+
|
| 129 |
+
rows = (
|
| 130 |
+
soup.select("#aod-offer-list #aod-offer")
|
| 131 |
+
or soup.select("#aod-offer")
|
| 132 |
+
or soup.select(".a-section.a-spacing-none.a-padding-base.olpOffer")
|
| 133 |
+
or soup.select("#olpOfferList .a-row")
|
| 134 |
+
or soup.select("[data-aod-offer]")
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
for row in rows[:25]:
|
| 138 |
+
offer = _parse_offer_row(row)
|
| 139 |
+
if not offer:
|
| 140 |
+
continue
|
| 141 |
+
key = offer["name"].lower()
|
| 142 |
+
if key in seen_names:
|
| 143 |
+
continue
|
| 144 |
+
seen_names.add(key)
|
| 145 |
+
offers.append(offer)
|
| 146 |
+
|
| 147 |
+
if offers:
|
| 148 |
+
break
|
| 149 |
+
except Exception as e:
|
| 150 |
+
print(f"[offers_scraper] parse {url}: {e}")
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
fba_count = sum(1 for o in offers if o.get("is_fba") is True)
|
| 154 |
+
fbm_count = sum(1 for o in offers if o.get("is_fba") is False)
|
| 155 |
+
seller_count = max(len(offers), 1)
|
| 156 |
+
|
| 157 |
+
return offers, seller_count, fba_count, fbm_count
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def enrich_product_offers(data: dict) -> dict:
|
| 161 |
+
"""Attach scraped offers to product payload; never synthesize fake sellers."""
|
| 162 |
+
asin = data.get("asin")
|
| 163 |
+
if not asin:
|
| 164 |
+
return data
|
| 165 |
+
|
| 166 |
+
offers, count, fba, fbm = scrape_offers(asin)
|
| 167 |
+
winner = (data.get("buy_box_winner") or "").strip().lower()
|
| 168 |
+
|
| 169 |
+
if offers:
|
| 170 |
+
# Drop duplicate of buy box winner from the "other" list
|
| 171 |
+
filtered = [o for o in offers if o.get("name", "").strip().lower() != winner]
|
| 172 |
+
data["other_sellers"] = filtered
|
| 173 |
+
data["seller_count"] = max(data.get("seller_count") or 1, len(filtered) + (1 if winner else 0), count)
|
| 174 |
+
data["fba_seller_count"] = fba if fba > 0 else None
|
| 175 |
+
data["fbm_seller_count"] = fbm if fbm > 0 else None
|
| 176 |
+
data["offers_source"] = "live_aod"
|
| 177 |
+
print(f"[offers_scraper] ✅ {asin}: {len(filtered)} competing offers (+ buy box winner)")
|
| 178 |
+
else:
|
| 179 |
+
data["other_sellers"] = data.get("other_sellers") or []
|
| 180 |
+
data["offers_source"] = "product_page_only"
|
| 181 |
+
return data
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def extract_package_weight(soup: BeautifulSoup) -> Optional[float]:
|
| 185 |
+
"""Extract item/package weight in pounds from product detail bullets."""
|
| 186 |
+
patterns = [
|
| 187 |
+
r"([\d.]+)\s*pounds",
|
| 188 |
+
r"([\d.]+)\s*lbs",
|
| 189 |
+
r"([\d.]+)\s*lb\b",
|
| 190 |
+
r"([\d.]+)\s*ounces",
|
| 191 |
+
r"([\d.]+)\s*oz\b",
|
| 192 |
+
]
|
| 193 |
+
for block in soup.select("#detailBullets_feature_div li, #productDetails_detailBullets_sections1 tr, .prodDetSectionEntry"):
|
| 194 |
+
text = block.get_text(" ", strip=True).lower()
|
| 195 |
+
if "weight" not in text and "shipping weight" not in text and "item weight" not in text:
|
| 196 |
+
continue
|
| 197 |
+
for pat in patterns:
|
| 198 |
+
m = re.search(pat, text)
|
| 199 |
+
if m:
|
| 200 |
+
val = float(m.group(1))
|
| 201 |
+
if "ounce" in text or " oz" in text:
|
| 202 |
+
return round(val / 16, 3)
|
| 203 |
+
return round(val, 3)
|
| 204 |
+
return None
|
app/services/amazon/product_scraper.py
CHANGED
|
@@ -1,417 +1,417 @@
|
|
| 1 |
-
import random
|
| 2 |
-
import re
|
| 3 |
-
import time
|
| 4 |
-
import requests
|
| 5 |
-
from bs4 import BeautifulSoup
|
| 6 |
-
from typing import Optional
|
| 7 |
-
|
| 8 |
-
from app.config import settings
|
| 9 |
-
from app.services.amazon.offers_scraper import enrich_product_offers, extract_package_weight
|
| 10 |
-
from app.services.amazon.scraper_utils import (
|
| 11 |
-
get_headers as _get_headers,
|
| 12 |
-
normalize_price as _normalize_price,
|
| 13 |
-
parse_int as _parse_int,
|
| 14 |
-
parse_number as _parse_number,
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _extract_upc(soup: BeautifulSoup) -> Optional[str]:
|
| 19 |
-
"""Extract UPC/EAN/GTIN from Amazon product detail sections."""
|
| 20 |
-
for row in soup.find_all("tr"):
|
| 21 |
-
th = row.find("th")
|
| 22 |
-
td = row.find("td")
|
| 23 |
-
if not th or not td:
|
| 24 |
-
continue
|
| 25 |
-
label = th.get_text(strip=True).lower()
|
| 26 |
-
if any(k in label for k in ("upc", "ean", "gtin")):
|
| 27 |
-
digits = re.sub(r"\D", "", td.get_text(strip=True))
|
| 28 |
-
if len(digits) >= 8:
|
| 29 |
-
return digits[:14]
|
| 30 |
-
|
| 31 |
-
for block in soup.select("#detailBullets_feature_div li, #productDetails_detailBullets_sections1 tr"):
|
| 32 |
-
text = block.get_text(" ", strip=True)
|
| 33 |
-
lower = text.lower()
|
| 34 |
-
if "upc" in lower or "ean" in lower or "gtin" in lower:
|
| 35 |
-
match = re.search(r"(\d{8,14})", text)
|
| 36 |
-
if match:
|
| 37 |
-
return match.group(1)
|
| 38 |
-
|
| 39 |
-
for script in soup.find_all("script", type="application/ld+json"):
|
| 40 |
-
raw = script.string or script.get_text()
|
| 41 |
-
if not raw or "gtin" not in raw.lower():
|
| 42 |
-
continue
|
| 43 |
-
match = re.search(r'"gtin(?:12|13|14)?"\s*:\s*"(\d{8,14})"', raw, re.I)
|
| 44 |
-
if match:
|
| 45 |
-
return match.group(1)
|
| 46 |
-
return None
|
| 47 |
-
|
| 48 |
-
def get_mock_product(asin: str) -> dict:
|
| 49 |
-
data = {
|
| 50 |
-
"asin": asin,
|
| 51 |
-
"title": f"Amazon Product {asin}",
|
| 52 |
-
"brand": "Amazon Brand",
|
| 53 |
-
"category": "Electronics",
|
| 54 |
-
"price": 29.99,
|
| 55 |
-
"rating": 4.3,
|
| 56 |
-
"review_count": 1250,
|
| 57 |
-
"bsr": 5000,
|
| 58 |
-
"image_url": f"https://via.placeholder.com/300x300?text={asin}",
|
| 59 |
-
"amazon_url": f"https://www.amazon.com/dp/{asin}",
|
| 60 |
-
"in_stock": True,
|
| 61 |
-
"is_prime": True,
|
| 62 |
-
"buy_box_winner": "Amazon.com",
|
| 63 |
-
"buy_box_price": 29.99,
|
| 64 |
-
"buy_box_is_fba": True,
|
| 65 |
-
"buy_box_is_prime": True,
|
| 66 |
-
"seller_count": 3,
|
| 67 |
-
"fba_seller_count": 3,
|
| 68 |
-
"is_amazon_sold": True,
|
| 69 |
-
"has_buy_box": True,
|
| 70 |
-
"upc": "012345678905",
|
| 71 |
-
"other_sellers": [
|
| 72 |
-
{"name": "Seller A", "price": 31.99, "is_fba": True, "rating": "97%"},
|
| 73 |
-
{"name": "Seller B", "price": 32.50, "is_fba": False, "rating": "94%"},
|
| 74 |
-
],
|
| 75 |
-
"data_source": "mock",
|
| 76 |
-
}
|
| 77 |
-
return data
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def _mock_or_none(asin: str) -> Optional[dict]:
|
| 81 |
-
if settings.allow_mock_data:
|
| 82 |
-
return get_mock_product(asin)
|
| 83 |
-
return None
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def _fetch_html(url: str, timeout: int = 60) -> Optional[str]:
|
| 87 |
-
"""Fetch Amazon HTML — ScraperAPI on cloud servers, direct fetch as fallback."""
|
| 88 |
-
if settings.scraper_api_key:
|
| 89 |
-
try:
|
| 90 |
-
resp = requests.get(
|
| 91 |
-
"https://api.scraperapi.com",
|
| 92 |
-
params={
|
| 93 |
-
"api_key": settings.scraper_api_key,
|
| 94 |
-
"url": url,
|
| 95 |
-
"country_code": "us",
|
| 96 |
-
},
|
| 97 |
-
timeout=timeout,
|
| 98 |
-
)
|
| 99 |
-
if resp.status_code == 200 and len(resp.text) > 500:
|
| 100 |
-
return resp.text
|
| 101 |
-
print(f"[scraper] ScraperAPI HTTP {resp.status_code} for {url[:70]}")
|
| 102 |
-
except Exception as e:
|
| 103 |
-
print(f"[scraper] ScraperAPI error: {e}")
|
| 104 |
-
|
| 105 |
-
try:
|
| 106 |
-
time.sleep(random.uniform(1.0, 2.5))
|
| 107 |
-
session = requests.Session()
|
| 108 |
-
try:
|
| 109 |
-
session.get("https://www.amazon.com", headers=_get_headers(), timeout=8)
|
| 110 |
-
time.sleep(random.uniform(0.5, 1.0))
|
| 111 |
-
except Exception:
|
| 112 |
-
pass
|
| 113 |
-
resp = session.get(url, headers=_get_headers(), timeout=12)
|
| 114 |
-
if resp.status_code == 200:
|
| 115 |
-
return resp.text
|
| 116 |
-
except Exception as e:
|
| 117 |
-
print(f"[scraper] Direct fetch error: {e}")
|
| 118 |
-
return None
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def scrape_amazon_product(asin: str) -> Optional[dict]:
|
| 122 |
-
url = f"https://www.amazon.com/dp/{asin}"
|
| 123 |
-
for attempt in range(2):
|
| 124 |
-
try:
|
| 125 |
-
html = _fetch_html(url)
|
| 126 |
-
if not html:
|
| 127 |
-
time.sleep(2)
|
| 128 |
-
continue
|
| 129 |
-
|
| 130 |
-
soup = BeautifulSoup(html, "html.parser")
|
| 131 |
-
page_text = soup.get_text().lower()
|
| 132 |
-
|
| 133 |
-
if "enter the characters you see below" in page_text or "sorry, we just need to make sure" in page_text:
|
| 134 |
-
return _mock_or_none(asin)
|
| 135 |
-
|
| 136 |
-
# ── Title ─────────────────────────────────────────────────────────
|
| 137 |
-
title = None
|
| 138 |
-
title_el = (
|
| 139 |
-
soup.find("span", id="productTitle") or
|
| 140 |
-
soup.find("h1", id="title") or
|
| 141 |
-
soup.find("span", {"class": "product-title-word-break"})
|
| 142 |
-
)
|
| 143 |
-
if title_el:
|
| 144 |
-
title = title_el.get_text(strip=True)
|
| 145 |
-
if not title:
|
| 146 |
-
return _mock_or_none(asin)
|
| 147 |
-
|
| 148 |
-
# ── Price ─────────────────────────────────────────────────────────
|
| 149 |
-
price = None
|
| 150 |
-
for tag, attrs in [
|
| 151 |
-
("span", {"class": "a-price-whole"}),
|
| 152 |
-
("span", {"id": "priceblock_ourprice"}),
|
| 153 |
-
("span", {"id": "priceblock_dealprice"}),
|
| 154 |
-
("span", {"class": "a-offscreen"}),
|
| 155 |
-
]:
|
| 156 |
-
el = soup.find(tag, attrs)
|
| 157 |
-
if el:
|
| 158 |
-
raw = _parse_number(el.get_text())
|
| 159 |
-
if raw and raw > 0:
|
| 160 |
-
price = raw
|
| 161 |
-
break
|
| 162 |
-
price = _normalize_price(price)
|
| 163 |
-
|
| 164 |
-
# ── Rating ────────────────────────────────────────────────────────
|
| 165 |
-
rating = None
|
| 166 |
-
rating_el = (
|
| 167 |
-
soup.find("span", {"data-hook": "rating-out-of-text"}) or
|
| 168 |
-
soup.find("span", {"class": "a-icon-alt"}) or
|
| 169 |
-
soup.find("i", {"data-hook": "average-star-rating"})
|
| 170 |
-
)
|
| 171 |
-
if rating_el:
|
| 172 |
-
match = re.search(r"(\d+\.?\d*)\s*out of", rating_el.get_text())
|
| 173 |
-
if match:
|
| 174 |
-
rating = float(match.group(1))
|
| 175 |
-
|
| 176 |
-
# ── Reviews ───────────────────────────────────────────────────────
|
| 177 |
-
review_count = None
|
| 178 |
-
review_el = (
|
| 179 |
-
soup.find("span", {"id": "acrCustomerReviewText"}) or
|
| 180 |
-
soup.find("span", {"data-hook": "total-review-count"})
|
| 181 |
-
)
|
| 182 |
-
if review_el:
|
| 183 |
-
review_count = _parse_int(review_el.get_text())
|
| 184 |
-
|
| 185 |
-
# ── BSR ───────────────────────────────────────────────────────────
|
| 186 |
-
bsr = None
|
| 187 |
-
bsr_section = soup.find("th", string=re.compile(r"Best Sellers Rank", re.I))
|
| 188 |
-
if not bsr_section:
|
| 189 |
-
bsr_section = soup.find("span", string=re.compile(r"Best Sellers Rank", re.I))
|
| 190 |
-
if bsr_section:
|
| 191 |
-
bsr_td = bsr_section.find_next("td") or bsr_section.find_next("span")
|
| 192 |
-
if bsr_td:
|
| 193 |
-
match = re.search(r"#([\d,]+)", bsr_td.get_text())
|
| 194 |
-
if match:
|
| 195 |
-
bsr = _parse_int(match.group(1))
|
| 196 |
-
if not bsr:
|
| 197 |
-
for row in soup.find_all("tr"):
|
| 198 |
-
th = row.find("th")
|
| 199 |
-
td = row.find("td")
|
| 200 |
-
if th and td and "best sellers rank" in th.get_text().lower():
|
| 201 |
-
match = re.search(r"#([\d,]+)", td.get_text())
|
| 202 |
-
if match:
|
| 203 |
-
bsr = _parse_int(match.group(1))
|
| 204 |
-
break
|
| 205 |
-
|
| 206 |
-
# ── Brand ─────────────────────────────────────────────────────────
|
| 207 |
-
brand = None
|
| 208 |
-
brand_el = soup.find("a", {"id": "bylineInfo"}) or soup.find("span", {"class": "author"})
|
| 209 |
-
if brand_el:
|
| 210 |
-
brand = re.sub(r"^(Visit the |Brand: )", "", brand_el.get_text(strip=True)).strip()
|
| 211 |
-
brand = re.sub(r"\s+Store$", "", brand, flags=re.I).strip()
|
| 212 |
-
|
| 213 |
-
# ── UPC ───────────────────────────────────────────────────────────
|
| 214 |
-
upc = _extract_upc(soup)
|
| 215 |
-
|
| 216 |
-
# ── Category ──────────────────────────────────────────────────────
|
| 217 |
-
category = None
|
| 218 |
-
breadcrumb = soup.find("div", {"id": "wayfinding-breadcrumbs_feature_div"})
|
| 219 |
-
if breadcrumb:
|
| 220 |
-
crumbs = breadcrumb.find_all("a")
|
| 221 |
-
if crumbs:
|
| 222 |
-
category = crumbs[0].get_text(strip=True)
|
| 223 |
-
|
| 224 |
-
# ── Image ─────────────────────────────────────────────────────────
|
| 225 |
-
image_url = None
|
| 226 |
-
img_el = soup.find("img", {"id": "landingImage"}) or soup.find("img", {"id": "imgBlkFront"})
|
| 227 |
-
if img_el:
|
| 228 |
-
image_url = img_el.get("src") or img_el.get("data-old-hires")
|
| 229 |
-
|
| 230 |
-
# ── In Stock ──────────────────────────────────────────────────────
|
| 231 |
-
in_stock = True
|
| 232 |
-
availability_el = soup.find("div", {"id": "availability"})
|
| 233 |
-
if availability_el:
|
| 234 |
-
avail_text = availability_el.get_text(strip=True).lower()
|
| 235 |
-
in_stock = "in stock" in avail_text or "available" in avail_text
|
| 236 |
-
|
| 237 |
-
# ── Is Prime ──────────────────────────────────────────────────────
|
| 238 |
-
is_prime = soup.find("i", {"class": "a-icon-prime"}) is not None
|
| 239 |
-
|
| 240 |
-
# ════════════════════════════════════════════════════════════════
|
| 241 |
-
# BUY BOX DATA
|
| 242 |
-
# ════════════════════════════════════════════════════════════════
|
| 243 |
-
|
| 244 |
-
buy_box_winner = None
|
| 245 |
-
buy_box_price = price
|
| 246 |
-
buy_box_is_fba = False
|
| 247 |
-
buy_box_is_prime = is_prime
|
| 248 |
-
is_amazon_sold = False
|
| 249 |
-
seller_count = 1
|
| 250 |
-
fba_seller_count = 0
|
| 251 |
-
other_sellers = []
|
| 252 |
-
has_buy_box = True
|
| 253 |
-
|
| 254 |
-
# Who owns the buy box
|
| 255 |
-
merchant_el = (
|
| 256 |
-
soup.find("div", {"id": "merchant-info"}) or
|
| 257 |
-
soup.find("a", {"id": "sellerProfileTriggerId"}) or
|
| 258 |
-
soup.find("div", {"id": "tabular-buybox-truncate-0"})
|
| 259 |
-
)
|
| 260 |
-
if merchant_el:
|
| 261 |
-
merchant_text = merchant_el.get_text(strip=True)
|
| 262 |
-
if "amazon" in merchant_text.lower():
|
| 263 |
-
buy_box_winner = "Amazon.com"
|
| 264 |
-
is_amazon_sold = True
|
| 265 |
-
else:
|
| 266 |
-
buy_box_winner = merchant_text[:50]
|
| 267 |
-
|
| 268 |
-
# Sold by Amazon check
|
| 269 |
-
sold_by_el = soup.find(string=re.compile(r"Sold by Amazon", re.I))
|
| 270 |
-
if sold_by_el:
|
| 271 |
-
buy_box_winner = "Amazon.com"
|
| 272 |
-
is_amazon_sold = True
|
| 273 |
-
|
| 274 |
-
# Ships from / sold by block
|
| 275 |
-
ships_block = soup.find("div", {"id": "tabular-buybox"})
|
| 276 |
-
if ships_block:
|
| 277 |
-
text = ships_block.get_text()
|
| 278 |
-
if "amazon" in text.lower():
|
| 279 |
-
is_amazon_sold = True
|
| 280 |
-
buy_box_winner = buy_box_winner or "Amazon.com"
|
| 281 |
-
|
| 282 |
-
# FBA check
|
| 283 |
-
fba_el = soup.find(string=re.compile(r"Fulfilled by Amazon", re.I))
|
| 284 |
-
if fba_el:
|
| 285 |
-
buy_box_is_fba = True
|
| 286 |
-
|
| 287 |
-
# FBM check — overrides if explicitly merchant fulfilled
|
| 288 |
-
fbm_el = soup.find(string=re.compile(r"Fulfillment by Merchant|Ships from.*sold by", re.I))
|
| 289 |
-
if fbm_el and not fba_el:
|
| 290 |
-
buy_box_is_fba = False
|
| 291 |
-
|
| 292 |
-
# Number of other sellers
|
| 293 |
-
sellers_el = soup.find("a", {"id": "olp-sl-new"}) or soup.find("span", string=re.compile(r"new from", re.I))
|
| 294 |
-
if sellers_el:
|
| 295 |
-
match = re.search(r"(\d+)", sellers_el.get_text())
|
| 296 |
-
if match:
|
| 297 |
-
seller_count = int(match.group(1))
|
| 298 |
-
|
| 299 |
-
# Offer listing page — get other sellers
|
| 300 |
-
offer_el = soup.find("a", href=re.compile(r"/gp/offer-listing/"))
|
| 301 |
-
if offer_el:
|
| 302 |
-
offer_text = offer_el.get_text(strip=True)
|
| 303 |
-
match = re.search(r"(\d+)", offer_text)
|
| 304 |
-
if match:
|
| 305 |
-
seller_count = max(seller_count, int(match.group(1)))
|
| 306 |
-
|
| 307 |
-
# Buy box price
|
| 308 |
-
buybox_price_el = (
|
| 309 |
-
soup.find("span", {"id": "price_inside_buybox"}) or
|
| 310 |
-
soup.find("span", {"id": "priceblock_ourprice"})
|
| 311 |
-
)
|
| 312 |
-
if buybox_price_el:
|
| 313 |
-
bb_price = _parse_number(buybox_price_el.get_text())
|
| 314 |
-
if bb_price:
|
| 315 |
-
buy_box_price = _normalize_price(bb_price)
|
| 316 |
-
|
| 317 |
-
# Buy box availability
|
| 318 |
-
no_buybox_signals = (
|
| 319 |
-
soup.find("div", {"id": "outOfStock"})
|
| 320 |
-
or soup.find("span", {"id": "buybox-see-all-buying-choices"})
|
| 321 |
-
or soup.find(string=re.compile(r"no featured offers|currently unavailable", re.I))
|
| 322 |
-
)
|
| 323 |
-
add_to_cart = (
|
| 324 |
-
soup.find("input", {"id": "add-to-cart-button"})
|
| 325 |
-
or soup.find("span", {"id": "submit.add-to-cart"})
|
| 326 |
-
or soup.find("input", {"id": "buy-now-button"})
|
| 327 |
-
)
|
| 328 |
-
if no_buybox_signals and not add_to_cart:
|
| 329 |
-
has_buy_box = False
|
| 330 |
-
if not buy_box_price and not add_to_cart:
|
| 331 |
-
has_buy_box = False
|
| 332 |
-
if not in_stock:
|
| 333 |
-
has_buy_box = False
|
| 334 |
-
|
| 335 |
-
# Estimate FBA vs FBM seller split only when offers scrape did not run
|
| 336 |
-
if not other_sellers:
|
| 337 |
-
if buy_box_is_fba and has_buy_box:
|
| 338 |
-
fba_seller_count = max(1, seller_count) if seller_count > 0 else 1
|
| 339 |
-
fbm_seller_count = max(0, seller_count - fba_seller_count)
|
| 340 |
-
elif not buy_box_is_fba and has_buy_box:
|
| 341 |
-
fbm_seller_count = max(1, seller_count) if seller_count > 0 else 1
|
| 342 |
-
fba_seller_count = max(0, seller_count - fbm_seller_count)
|
| 343 |
-
|
| 344 |
-
package_weight_lbs = extract_package_weight(soup)
|
| 345 |
-
|
| 346 |
-
print(f"[scraper] ✅ {asin}: price=${price}, bsr={bsr}, rating={rating}, reviews={review_count}, buy_box={buy_box_winner}, sellers={seller_count}")
|
| 347 |
-
|
| 348 |
-
payload = {
|
| 349 |
-
"asin": asin,
|
| 350 |
-
"title": title,
|
| 351 |
-
"brand": brand or "Unknown",
|
| 352 |
-
"upc": upc,
|
| 353 |
-
"category": category or "General",
|
| 354 |
-
"image_url": image_url or "",
|
| 355 |
-
"amazon_url": url,
|
| 356 |
-
"price": price,
|
| 357 |
-
"bsr": bsr,
|
| 358 |
-
"rating": rating,
|
| 359 |
-
"review_count": review_count,
|
| 360 |
-
"in_stock": in_stock,
|
| 361 |
-
"is_prime": is_prime,
|
| 362 |
-
# Buy Box data
|
| 363 |
-
"buy_box_winner": buy_box_winner,
|
| 364 |
-
"buy_box_price": buy_box_price,
|
| 365 |
-
"buy_box_is_fba": buy_box_is_fba,
|
| 366 |
-
"buy_box_is_prime": buy_box_is_prime,
|
| 367 |
-
"seller_count": seller_count,
|
| 368 |
-
"fba_seller_count": fba_seller_count,
|
| 369 |
-
"fbm_seller_count": fbm_seller_count,
|
| 370 |
-
"has_buy_box": has_buy_box,
|
| 371 |
-
"is_amazon_sold": is_amazon_sold,
|
| 372 |
-
"other_sellers": other_sellers,
|
| 373 |
-
"package_weight_lbs": package_weight_lbs,
|
| 374 |
-
"data_source": "live",
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
if not buy_box_winner and has_buy_box:
|
| 378 |
-
buy_box_winner = "3rd Party Seller" if not is_amazon_sold else "Amazon.com"
|
| 379 |
-
payload["buy_box_winner"] = buy_box_winner
|
| 380 |
-
elif not has_buy_box:
|
| 381 |
-
payload["buy_box_winner"] = None
|
| 382 |
-
payload["buy_box_price"] = None
|
| 383 |
-
|
| 384 |
-
return enrich_product_offers(payload)
|
| 385 |
-
|
| 386 |
-
except requests.exceptions.Timeout:
|
| 387 |
-
time.sleep(2)
|
| 388 |
-
except requests.exceptions.RequestException:
|
| 389 |
-
time.sleep(2)
|
| 390 |
-
except Exception as e:
|
| 391 |
-
print(f"[scraper] Unexpected error: {e}")
|
| 392 |
-
return _mock_or_none(asin)
|
| 393 |
-
|
| 394 |
-
return _mock_or_none(asin)
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
def search_asin_by_keyword(keyword: str) -> Optional[str]:
|
| 398 |
-
"""Find the first ASIN from Amazon search results for a keyword."""
|
| 399 |
-
if not keyword or not keyword.strip():
|
| 400 |
-
return None
|
| 401 |
-
q = requests.utils.quote(keyword.strip())
|
| 402 |
-
url = f"https://www.amazon.com/s?k={q}"
|
| 403 |
-
try:
|
| 404 |
-
html = _fetch_html(url, timeout=45)
|
| 405 |
-
if not html:
|
| 406 |
-
return None
|
| 407 |
-
soup = BeautifulSoup(html, "html.parser")
|
| 408 |
-
for link in soup.find_all("a", href=True):
|
| 409 |
-
match = re.search(r"/dp/([A-Z0-9]{10})", link["href"])
|
| 410 |
-
if match:
|
| 411 |
-
return match.group(1)
|
| 412 |
-
match = re.search(r"/gp/product/([A-Z0-9]{10})", link["href"])
|
| 413 |
-
if match:
|
| 414 |
-
return match.group(1)
|
| 415 |
-
except Exception as e:
|
| 416 |
-
print(f"[scraper] Keyword search error: {e}")
|
| 417 |
return None
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
import requests
|
| 5 |
+
from bs4 import BeautifulSoup
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from app.services.amazon.offers_scraper import enrich_product_offers, extract_package_weight
|
| 10 |
+
from app.services.amazon.scraper_utils import (
|
| 11 |
+
get_headers as _get_headers,
|
| 12 |
+
normalize_price as _normalize_price,
|
| 13 |
+
parse_int as _parse_int,
|
| 14 |
+
parse_number as _parse_number,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _extract_upc(soup: BeautifulSoup) -> Optional[str]:
|
| 19 |
+
"""Extract UPC/EAN/GTIN from Amazon product detail sections."""
|
| 20 |
+
for row in soup.find_all("tr"):
|
| 21 |
+
th = row.find("th")
|
| 22 |
+
td = row.find("td")
|
| 23 |
+
if not th or not td:
|
| 24 |
+
continue
|
| 25 |
+
label = th.get_text(strip=True).lower()
|
| 26 |
+
if any(k in label for k in ("upc", "ean", "gtin")):
|
| 27 |
+
digits = re.sub(r"\D", "", td.get_text(strip=True))
|
| 28 |
+
if len(digits) >= 8:
|
| 29 |
+
return digits[:14]
|
| 30 |
+
|
| 31 |
+
for block in soup.select("#detailBullets_feature_div li, #productDetails_detailBullets_sections1 tr"):
|
| 32 |
+
text = block.get_text(" ", strip=True)
|
| 33 |
+
lower = text.lower()
|
| 34 |
+
if "upc" in lower or "ean" in lower or "gtin" in lower:
|
| 35 |
+
match = re.search(r"(\d{8,14})", text)
|
| 36 |
+
if match:
|
| 37 |
+
return match.group(1)
|
| 38 |
+
|
| 39 |
+
for script in soup.find_all("script", type="application/ld+json"):
|
| 40 |
+
raw = script.string or script.get_text()
|
| 41 |
+
if not raw or "gtin" not in raw.lower():
|
| 42 |
+
continue
|
| 43 |
+
match = re.search(r'"gtin(?:12|13|14)?"\s*:\s*"(\d{8,14})"', raw, re.I)
|
| 44 |
+
if match:
|
| 45 |
+
return match.group(1)
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
def get_mock_product(asin: str) -> dict:
|
| 49 |
+
data = {
|
| 50 |
+
"asin": asin,
|
| 51 |
+
"title": f"Amazon Product {asin}",
|
| 52 |
+
"brand": "Amazon Brand",
|
| 53 |
+
"category": "Electronics",
|
| 54 |
+
"price": 29.99,
|
| 55 |
+
"rating": 4.3,
|
| 56 |
+
"review_count": 1250,
|
| 57 |
+
"bsr": 5000,
|
| 58 |
+
"image_url": f"https://via.placeholder.com/300x300?text={asin}",
|
| 59 |
+
"amazon_url": f"https://www.amazon.com/dp/{asin}",
|
| 60 |
+
"in_stock": True,
|
| 61 |
+
"is_prime": True,
|
| 62 |
+
"buy_box_winner": "Amazon.com",
|
| 63 |
+
"buy_box_price": 29.99,
|
| 64 |
+
"buy_box_is_fba": True,
|
| 65 |
+
"buy_box_is_prime": True,
|
| 66 |
+
"seller_count": 3,
|
| 67 |
+
"fba_seller_count": 3,
|
| 68 |
+
"is_amazon_sold": True,
|
| 69 |
+
"has_buy_box": True,
|
| 70 |
+
"upc": "012345678905",
|
| 71 |
+
"other_sellers": [
|
| 72 |
+
{"name": "Seller A", "price": 31.99, "is_fba": True, "rating": "97%"},
|
| 73 |
+
{"name": "Seller B", "price": 32.50, "is_fba": False, "rating": "94%"},
|
| 74 |
+
],
|
| 75 |
+
"data_source": "mock",
|
| 76 |
+
}
|
| 77 |
+
return data
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _mock_or_none(asin: str) -> Optional[dict]:
|
| 81 |
+
if settings.allow_mock_data:
|
| 82 |
+
return get_mock_product(asin)
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _fetch_html(url: str, timeout: int = 60) -> Optional[str]:
|
| 87 |
+
"""Fetch Amazon HTML — ScraperAPI on cloud servers, direct fetch as fallback."""
|
| 88 |
+
if settings.scraper_api_key:
|
| 89 |
+
try:
|
| 90 |
+
resp = requests.get(
|
| 91 |
+
"https://api.scraperapi.com",
|
| 92 |
+
params={
|
| 93 |
+
"api_key": settings.scraper_api_key,
|
| 94 |
+
"url": url,
|
| 95 |
+
"country_code": "us",
|
| 96 |
+
},
|
| 97 |
+
timeout=timeout,
|
| 98 |
+
)
|
| 99 |
+
if resp.status_code == 200 and len(resp.text) > 500:
|
| 100 |
+
return resp.text
|
| 101 |
+
print(f"[scraper] ScraperAPI HTTP {resp.status_code} for {url[:70]}")
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f"[scraper] ScraperAPI error: {e}")
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
time.sleep(random.uniform(1.0, 2.5))
|
| 107 |
+
session = requests.Session()
|
| 108 |
+
try:
|
| 109 |
+
session.get("https://www.amazon.com", headers=_get_headers(), timeout=8)
|
| 110 |
+
time.sleep(random.uniform(0.5, 1.0))
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
resp = session.get(url, headers=_get_headers(), timeout=12)
|
| 114 |
+
if resp.status_code == 200:
|
| 115 |
+
return resp.text
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"[scraper] Direct fetch error: {e}")
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def scrape_amazon_product(asin: str) -> Optional[dict]:
|
| 122 |
+
url = f"https://www.amazon.com/dp/{asin}"
|
| 123 |
+
for attempt in range(2):
|
| 124 |
+
try:
|
| 125 |
+
html = _fetch_html(url)
|
| 126 |
+
if not html:
|
| 127 |
+
time.sleep(2)
|
| 128 |
+
continue
|
| 129 |
+
|
| 130 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 131 |
+
page_text = soup.get_text().lower()
|
| 132 |
+
|
| 133 |
+
if "enter the characters you see below" in page_text or "sorry, we just need to make sure" in page_text:
|
| 134 |
+
return _mock_or_none(asin)
|
| 135 |
+
|
| 136 |
+
# ── Title ─────────────────────────────────────────────────────────
|
| 137 |
+
title = None
|
| 138 |
+
title_el = (
|
| 139 |
+
soup.find("span", id="productTitle") or
|
| 140 |
+
soup.find("h1", id="title") or
|
| 141 |
+
soup.find("span", {"class": "product-title-word-break"})
|
| 142 |
+
)
|
| 143 |
+
if title_el:
|
| 144 |
+
title = title_el.get_text(strip=True)
|
| 145 |
+
if not title:
|
| 146 |
+
return _mock_or_none(asin)
|
| 147 |
+
|
| 148 |
+
# ── Price ─────────────────────────────────────────────────────────
|
| 149 |
+
price = None
|
| 150 |
+
for tag, attrs in [
|
| 151 |
+
("span", {"class": "a-price-whole"}),
|
| 152 |
+
("span", {"id": "priceblock_ourprice"}),
|
| 153 |
+
("span", {"id": "priceblock_dealprice"}),
|
| 154 |
+
("span", {"class": "a-offscreen"}),
|
| 155 |
+
]:
|
| 156 |
+
el = soup.find(tag, attrs)
|
| 157 |
+
if el:
|
| 158 |
+
raw = _parse_number(el.get_text())
|
| 159 |
+
if raw and raw > 0:
|
| 160 |
+
price = raw
|
| 161 |
+
break
|
| 162 |
+
price = _normalize_price(price)
|
| 163 |
+
|
| 164 |
+
# ── Rating ────────────────────────────────────────────────────────
|
| 165 |
+
rating = None
|
| 166 |
+
rating_el = (
|
| 167 |
+
soup.find("span", {"data-hook": "rating-out-of-text"}) or
|
| 168 |
+
soup.find("span", {"class": "a-icon-alt"}) or
|
| 169 |
+
soup.find("i", {"data-hook": "average-star-rating"})
|
| 170 |
+
)
|
| 171 |
+
if rating_el:
|
| 172 |
+
match = re.search(r"(\d+\.?\d*)\s*out of", rating_el.get_text())
|
| 173 |
+
if match:
|
| 174 |
+
rating = float(match.group(1))
|
| 175 |
+
|
| 176 |
+
# ── Reviews ───────────────────────────────────────────────────────
|
| 177 |
+
review_count = None
|
| 178 |
+
review_el = (
|
| 179 |
+
soup.find("span", {"id": "acrCustomerReviewText"}) or
|
| 180 |
+
soup.find("span", {"data-hook": "total-review-count"})
|
| 181 |
+
)
|
| 182 |
+
if review_el:
|
| 183 |
+
review_count = _parse_int(review_el.get_text())
|
| 184 |
+
|
| 185 |
+
# ── BSR ───────────────────────────────────────────────────────────
|
| 186 |
+
bsr = None
|
| 187 |
+
bsr_section = soup.find("th", string=re.compile(r"Best Sellers Rank", re.I))
|
| 188 |
+
if not bsr_section:
|
| 189 |
+
bsr_section = soup.find("span", string=re.compile(r"Best Sellers Rank", re.I))
|
| 190 |
+
if bsr_section:
|
| 191 |
+
bsr_td = bsr_section.find_next("td") or bsr_section.find_next("span")
|
| 192 |
+
if bsr_td:
|
| 193 |
+
match = re.search(r"#([\d,]+)", bsr_td.get_text())
|
| 194 |
+
if match:
|
| 195 |
+
bsr = _parse_int(match.group(1))
|
| 196 |
+
if not bsr:
|
| 197 |
+
for row in soup.find_all("tr"):
|
| 198 |
+
th = row.find("th")
|
| 199 |
+
td = row.find("td")
|
| 200 |
+
if th and td and "best sellers rank" in th.get_text().lower():
|
| 201 |
+
match = re.search(r"#([\d,]+)", td.get_text())
|
| 202 |
+
if match:
|
| 203 |
+
bsr = _parse_int(match.group(1))
|
| 204 |
+
break
|
| 205 |
+
|
| 206 |
+
# ── Brand ─────────────────────────────────────────────────────────
|
| 207 |
+
brand = None
|
| 208 |
+
brand_el = soup.find("a", {"id": "bylineInfo"}) or soup.find("span", {"class": "author"})
|
| 209 |
+
if brand_el:
|
| 210 |
+
brand = re.sub(r"^(Visit the |Brand: )", "", brand_el.get_text(strip=True)).strip()
|
| 211 |
+
brand = re.sub(r"\s+Store$", "", brand, flags=re.I).strip()
|
| 212 |
+
|
| 213 |
+
# ── UPC ───────────────────────────────────────────────────────────
|
| 214 |
+
upc = _extract_upc(soup)
|
| 215 |
+
|
| 216 |
+
# ── Category ──────────────────────────────────────────────────────
|
| 217 |
+
category = None
|
| 218 |
+
breadcrumb = soup.find("div", {"id": "wayfinding-breadcrumbs_feature_div"})
|
| 219 |
+
if breadcrumb:
|
| 220 |
+
crumbs = breadcrumb.find_all("a")
|
| 221 |
+
if crumbs:
|
| 222 |
+
category = crumbs[0].get_text(strip=True)
|
| 223 |
+
|
| 224 |
+
# ── Image ─────────────────────────────────────────────────────────
|
| 225 |
+
image_url = None
|
| 226 |
+
img_el = soup.find("img", {"id": "landingImage"}) or soup.find("img", {"id": "imgBlkFront"})
|
| 227 |
+
if img_el:
|
| 228 |
+
image_url = img_el.get("src") or img_el.get("data-old-hires")
|
| 229 |
+
|
| 230 |
+
# ── In Stock ──────────────────────────────────────────────────────
|
| 231 |
+
in_stock = True
|
| 232 |
+
availability_el = soup.find("div", {"id": "availability"})
|
| 233 |
+
if availability_el:
|
| 234 |
+
avail_text = availability_el.get_text(strip=True).lower()
|
| 235 |
+
in_stock = "in stock" in avail_text or "available" in avail_text
|
| 236 |
+
|
| 237 |
+
# ── Is Prime ──────────────────────────────────────────────────────
|
| 238 |
+
is_prime = soup.find("i", {"class": "a-icon-prime"}) is not None
|
| 239 |
+
|
| 240 |
+
# ════════════════════════════════════════════════════════════════
|
| 241 |
+
# BUY BOX DATA
|
| 242 |
+
# ════════════════════════════════════════════════════════════════
|
| 243 |
+
|
| 244 |
+
buy_box_winner = None
|
| 245 |
+
buy_box_price = price
|
| 246 |
+
buy_box_is_fba = False
|
| 247 |
+
buy_box_is_prime = is_prime
|
| 248 |
+
is_amazon_sold = False
|
| 249 |
+
seller_count = 1
|
| 250 |
+
fba_seller_count = 0
|
| 251 |
+
other_sellers = []
|
| 252 |
+
has_buy_box = True
|
| 253 |
+
|
| 254 |
+
# Who owns the buy box
|
| 255 |
+
merchant_el = (
|
| 256 |
+
soup.find("div", {"id": "merchant-info"}) or
|
| 257 |
+
soup.find("a", {"id": "sellerProfileTriggerId"}) or
|
| 258 |
+
soup.find("div", {"id": "tabular-buybox-truncate-0"})
|
| 259 |
+
)
|
| 260 |
+
if merchant_el:
|
| 261 |
+
merchant_text = merchant_el.get_text(strip=True)
|
| 262 |
+
if "amazon" in merchant_text.lower():
|
| 263 |
+
buy_box_winner = "Amazon.com"
|
| 264 |
+
is_amazon_sold = True
|
| 265 |
+
else:
|
| 266 |
+
buy_box_winner = merchant_text[:50]
|
| 267 |
+
|
| 268 |
+
# Sold by Amazon check
|
| 269 |
+
sold_by_el = soup.find(string=re.compile(r"Sold by Amazon", re.I))
|
| 270 |
+
if sold_by_el:
|
| 271 |
+
buy_box_winner = "Amazon.com"
|
| 272 |
+
is_amazon_sold = True
|
| 273 |
+
|
| 274 |
+
# Ships from / sold by block
|
| 275 |
+
ships_block = soup.find("div", {"id": "tabular-buybox"})
|
| 276 |
+
if ships_block:
|
| 277 |
+
text = ships_block.get_text()
|
| 278 |
+
if "amazon" in text.lower():
|
| 279 |
+
is_amazon_sold = True
|
| 280 |
+
buy_box_winner = buy_box_winner or "Amazon.com"
|
| 281 |
+
|
| 282 |
+
# FBA check
|
| 283 |
+
fba_el = soup.find(string=re.compile(r"Fulfilled by Amazon", re.I))
|
| 284 |
+
if fba_el:
|
| 285 |
+
buy_box_is_fba = True
|
| 286 |
+
|
| 287 |
+
# FBM check — overrides if explicitly merchant fulfilled
|
| 288 |
+
fbm_el = soup.find(string=re.compile(r"Fulfillment by Merchant|Ships from.*sold by", re.I))
|
| 289 |
+
if fbm_el and not fba_el:
|
| 290 |
+
buy_box_is_fba = False
|
| 291 |
+
|
| 292 |
+
# Number of other sellers
|
| 293 |
+
sellers_el = soup.find("a", {"id": "olp-sl-new"}) or soup.find("span", string=re.compile(r"new from", re.I))
|
| 294 |
+
if sellers_el:
|
| 295 |
+
match = re.search(r"(\d+)", sellers_el.get_text())
|
| 296 |
+
if match:
|
| 297 |
+
seller_count = int(match.group(1))
|
| 298 |
+
|
| 299 |
+
# Offer listing page — get other sellers
|
| 300 |
+
offer_el = soup.find("a", href=re.compile(r"/gp/offer-listing/"))
|
| 301 |
+
if offer_el:
|
| 302 |
+
offer_text = offer_el.get_text(strip=True)
|
| 303 |
+
match = re.search(r"(\d+)", offer_text)
|
| 304 |
+
if match:
|
| 305 |
+
seller_count = max(seller_count, int(match.group(1)))
|
| 306 |
+
|
| 307 |
+
# Buy box price
|
| 308 |
+
buybox_price_el = (
|
| 309 |
+
soup.find("span", {"id": "price_inside_buybox"}) or
|
| 310 |
+
soup.find("span", {"id": "priceblock_ourprice"})
|
| 311 |
+
)
|
| 312 |
+
if buybox_price_el:
|
| 313 |
+
bb_price = _parse_number(buybox_price_el.get_text())
|
| 314 |
+
if bb_price:
|
| 315 |
+
buy_box_price = _normalize_price(bb_price)
|
| 316 |
+
|
| 317 |
+
# Buy box availability
|
| 318 |
+
no_buybox_signals = (
|
| 319 |
+
soup.find("div", {"id": "outOfStock"})
|
| 320 |
+
or soup.find("span", {"id": "buybox-see-all-buying-choices"})
|
| 321 |
+
or soup.find(string=re.compile(r"no featured offers|currently unavailable", re.I))
|
| 322 |
+
)
|
| 323 |
+
add_to_cart = (
|
| 324 |
+
soup.find("input", {"id": "add-to-cart-button"})
|
| 325 |
+
or soup.find("span", {"id": "submit.add-to-cart"})
|
| 326 |
+
or soup.find("input", {"id": "buy-now-button"})
|
| 327 |
+
)
|
| 328 |
+
if no_buybox_signals and not add_to_cart:
|
| 329 |
+
has_buy_box = False
|
| 330 |
+
if not buy_box_price and not add_to_cart:
|
| 331 |
+
has_buy_box = False
|
| 332 |
+
if not in_stock:
|
| 333 |
+
has_buy_box = False
|
| 334 |
+
|
| 335 |
+
# Estimate FBA vs FBM seller split only when offers scrape did not run
|
| 336 |
+
if not other_sellers:
|
| 337 |
+
if buy_box_is_fba and has_buy_box:
|
| 338 |
+
fba_seller_count = max(1, seller_count) if seller_count > 0 else 1
|
| 339 |
+
fbm_seller_count = max(0, seller_count - fba_seller_count)
|
| 340 |
+
elif not buy_box_is_fba and has_buy_box:
|
| 341 |
+
fbm_seller_count = max(1, seller_count) if seller_count > 0 else 1
|
| 342 |
+
fba_seller_count = max(0, seller_count - fbm_seller_count)
|
| 343 |
+
|
| 344 |
+
package_weight_lbs = extract_package_weight(soup)
|
| 345 |
+
|
| 346 |
+
print(f"[scraper] ✅ {asin}: price=${price}, bsr={bsr}, rating={rating}, reviews={review_count}, buy_box={buy_box_winner}, sellers={seller_count}")
|
| 347 |
+
|
| 348 |
+
payload = {
|
| 349 |
+
"asin": asin,
|
| 350 |
+
"title": title,
|
| 351 |
+
"brand": brand or "Unknown",
|
| 352 |
+
"upc": upc,
|
| 353 |
+
"category": category or "General",
|
| 354 |
+
"image_url": image_url or "",
|
| 355 |
+
"amazon_url": url,
|
| 356 |
+
"price": price,
|
| 357 |
+
"bsr": bsr,
|
| 358 |
+
"rating": rating,
|
| 359 |
+
"review_count": review_count,
|
| 360 |
+
"in_stock": in_stock,
|
| 361 |
+
"is_prime": is_prime,
|
| 362 |
+
# Buy Box data
|
| 363 |
+
"buy_box_winner": buy_box_winner,
|
| 364 |
+
"buy_box_price": buy_box_price,
|
| 365 |
+
"buy_box_is_fba": buy_box_is_fba,
|
| 366 |
+
"buy_box_is_prime": buy_box_is_prime,
|
| 367 |
+
"seller_count": seller_count,
|
| 368 |
+
"fba_seller_count": fba_seller_count,
|
| 369 |
+
"fbm_seller_count": fbm_seller_count,
|
| 370 |
+
"has_buy_box": has_buy_box,
|
| 371 |
+
"is_amazon_sold": is_amazon_sold,
|
| 372 |
+
"other_sellers": other_sellers,
|
| 373 |
+
"package_weight_lbs": package_weight_lbs,
|
| 374 |
+
"data_source": "live",
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
if not buy_box_winner and has_buy_box:
|
| 378 |
+
buy_box_winner = "3rd Party Seller" if not is_amazon_sold else "Amazon.com"
|
| 379 |
+
payload["buy_box_winner"] = buy_box_winner
|
| 380 |
+
elif not has_buy_box:
|
| 381 |
+
payload["buy_box_winner"] = None
|
| 382 |
+
payload["buy_box_price"] = None
|
| 383 |
+
|
| 384 |
+
return enrich_product_offers(payload)
|
| 385 |
+
|
| 386 |
+
except requests.exceptions.Timeout:
|
| 387 |
+
time.sleep(2)
|
| 388 |
+
except requests.exceptions.RequestException:
|
| 389 |
+
time.sleep(2)
|
| 390 |
+
except Exception as e:
|
| 391 |
+
print(f"[scraper] Unexpected error: {e}")
|
| 392 |
+
return _mock_or_none(asin)
|
| 393 |
+
|
| 394 |
+
return _mock_or_none(asin)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def search_asin_by_keyword(keyword: str) -> Optional[str]:
|
| 398 |
+
"""Find the first ASIN from Amazon search results for a keyword."""
|
| 399 |
+
if not keyword or not keyword.strip():
|
| 400 |
+
return None
|
| 401 |
+
q = requests.utils.quote(keyword.strip())
|
| 402 |
+
url = f"https://www.amazon.com/s?k={q}"
|
| 403 |
+
try:
|
| 404 |
+
html = _fetch_html(url, timeout=45)
|
| 405 |
+
if not html:
|
| 406 |
+
return None
|
| 407 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 408 |
+
for link in soup.find_all("a", href=True):
|
| 409 |
+
match = re.search(r"/dp/([A-Z0-9]{10})", link["href"])
|
| 410 |
+
if match:
|
| 411 |
+
return match.group(1)
|
| 412 |
+
match = re.search(r"/gp/product/([A-Z0-9]{10})", link["href"])
|
| 413 |
+
if match:
|
| 414 |
+
return match.group(1)
|
| 415 |
+
except Exception as e:
|
| 416 |
+
print(f"[scraper] Keyword search error: {e}")
|
| 417 |
return None
|
app/services/amazon/sales_estimator.py
CHANGED
|
@@ -1,74 +1,74 @@
|
|
| 1 |
-
CATEGORY_CURVES = {
|
| 2 |
-
"home": [(1,45000),(100,7000),(1000,1400),(5000,380),(10000,180),(50000,30)],
|
| 3 |
-
"electronics": [(1,30000),(100,4000),(1000,800),(5000,200),(10000,90),(50000,15)],
|
| 4 |
-
"clothing": [(1,60000),(100,8000),(1000,1600),(5000,430),(10000,200),(50000,35)],
|
| 5 |
-
"sports": [(1,25000),(100,3200),(1000,650),(5000,160),(10000,75),(50000,12)],
|
| 6 |
-
"beauty": [(1,50000),(100,7500),(1000,1500),(5000,400),(10000,185),(50000,32)],
|
| 7 |
-
"toys": [(1,35000),(100,5000),(1000,1000),(5000,260),(10000,120),(50000,20)],
|
| 8 |
-
"books": [(1,3000),(100,450),(1000,80),(5000,18),(10000,8)],
|
| 9 |
-
"default": [(1,30000),(100,4200),(1000,850),(5000,215),(10000,100),(50000,17)],
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
def interpolate_sales(bsr: int, curve: list) -> int:
|
| 13 |
-
if bsr <= 0:
|
| 14 |
-
return 0
|
| 15 |
-
curve = sorted(curve, key=lambda x: x[0])
|
| 16 |
-
if bsr <= curve[0][0]:
|
| 17 |
-
return curve[0][1]
|
| 18 |
-
if bsr >= curve[-1][0]:
|
| 19 |
-
return max(0, curve[-1][1])
|
| 20 |
-
for i in range(len(curve) - 1):
|
| 21 |
-
bsr_low, sales_high = curve[i]
|
| 22 |
-
bsr_high, sales_low = curve[i + 1]
|
| 23 |
-
if bsr_low <= bsr <= bsr_high:
|
| 24 |
-
ratio = (bsr - bsr_low) / (bsr_high - bsr_low)
|
| 25 |
-
return max(0, int(sales_high - ratio * (sales_high - sales_low)))
|
| 26 |
-
return 0
|
| 27 |
-
|
| 28 |
-
def estimate_monthly_sales(bsr: int, category: str) -> dict:
|
| 29 |
-
if not bsr or bsr <= 0:
|
| 30 |
-
return {"monthly_units": None, "confidence": "low"}
|
| 31 |
-
|
| 32 |
-
category_lower = (category or "").lower()
|
| 33 |
-
curve = CATEGORY_CURVES["default"]
|
| 34 |
-
confidence = "medium"
|
| 35 |
-
|
| 36 |
-
for key, cat_curve in CATEGORY_CURVES.items():
|
| 37 |
-
if key != "default" and key in category_lower:
|
| 38 |
-
curve = cat_curve
|
| 39 |
-
confidence = "high"
|
| 40 |
-
break
|
| 41 |
-
|
| 42 |
-
monthly_units = interpolate_sales(bsr, curve)
|
| 43 |
-
return {"monthly_units": monthly_units, "confidence": confidence}
|
| 44 |
-
|
| 45 |
-
def calculate_opportunity_score(bsr, review_count, monthly_sales, seller_count) -> float:
|
| 46 |
-
score = 0
|
| 47 |
-
|
| 48 |
-
# Demand (30 pts)
|
| 49 |
-
if monthly_sales >= 3000: score += 30
|
| 50 |
-
elif monthly_sales >= 1500: score += 23
|
| 51 |
-
elif monthly_sales >= 800: score += 17
|
| 52 |
-
elif monthly_sales >= 300: score += 10
|
| 53 |
-
elif monthly_sales >= 100: score += 5
|
| 54 |
-
|
| 55 |
-
# Low competition reviews (30 pts)
|
| 56 |
-
if review_count < 100: score += 30
|
| 57 |
-
elif review_count < 500: score += 22
|
| 58 |
-
elif review_count < 1000: score += 15
|
| 59 |
-
elif review_count < 3000: score += 8
|
| 60 |
-
elif review_count < 8000: score += 3
|
| 61 |
-
|
| 62 |
-
# BSR strength (20 pts)
|
| 63 |
-
if bsr < 1000: score += 20
|
| 64 |
-
elif bsr < 5000: score += 15
|
| 65 |
-
elif bsr < 15000: score += 10
|
| 66 |
-
elif bsr < 50000: score += 5
|
| 67 |
-
|
| 68 |
-
# Seller count (20 pts)
|
| 69 |
-
if seller_count <= 1: score += 20
|
| 70 |
-
elif seller_count <= 3: score += 15
|
| 71 |
-
elif seller_count <= 8: score += 10
|
| 72 |
-
elif seller_count <= 15: score += 5
|
| 73 |
-
|
| 74 |
return round(min(100, score), 1)
|
|
|
|
| 1 |
+
CATEGORY_CURVES = {
|
| 2 |
+
"home": [(1,45000),(100,7000),(1000,1400),(5000,380),(10000,180),(50000,30)],
|
| 3 |
+
"electronics": [(1,30000),(100,4000),(1000,800),(5000,200),(10000,90),(50000,15)],
|
| 4 |
+
"clothing": [(1,60000),(100,8000),(1000,1600),(5000,430),(10000,200),(50000,35)],
|
| 5 |
+
"sports": [(1,25000),(100,3200),(1000,650),(5000,160),(10000,75),(50000,12)],
|
| 6 |
+
"beauty": [(1,50000),(100,7500),(1000,1500),(5000,400),(10000,185),(50000,32)],
|
| 7 |
+
"toys": [(1,35000),(100,5000),(1000,1000),(5000,260),(10000,120),(50000,20)],
|
| 8 |
+
"books": [(1,3000),(100,450),(1000,80),(5000,18),(10000,8)],
|
| 9 |
+
"default": [(1,30000),(100,4200),(1000,850),(5000,215),(10000,100),(50000,17)],
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
def interpolate_sales(bsr: int, curve: list) -> int:
|
| 13 |
+
if bsr <= 0:
|
| 14 |
+
return 0
|
| 15 |
+
curve = sorted(curve, key=lambda x: x[0])
|
| 16 |
+
if bsr <= curve[0][0]:
|
| 17 |
+
return curve[0][1]
|
| 18 |
+
if bsr >= curve[-1][0]:
|
| 19 |
+
return max(0, curve[-1][1])
|
| 20 |
+
for i in range(len(curve) - 1):
|
| 21 |
+
bsr_low, sales_high = curve[i]
|
| 22 |
+
bsr_high, sales_low = curve[i + 1]
|
| 23 |
+
if bsr_low <= bsr <= bsr_high:
|
| 24 |
+
ratio = (bsr - bsr_low) / (bsr_high - bsr_low)
|
| 25 |
+
return max(0, int(sales_high - ratio * (sales_high - sales_low)))
|
| 26 |
+
return 0
|
| 27 |
+
|
| 28 |
+
def estimate_monthly_sales(bsr: int, category: str) -> dict:
|
| 29 |
+
if not bsr or bsr <= 0:
|
| 30 |
+
return {"monthly_units": None, "confidence": "low"}
|
| 31 |
+
|
| 32 |
+
category_lower = (category or "").lower()
|
| 33 |
+
curve = CATEGORY_CURVES["default"]
|
| 34 |
+
confidence = "medium"
|
| 35 |
+
|
| 36 |
+
for key, cat_curve in CATEGORY_CURVES.items():
|
| 37 |
+
if key != "default" and key in category_lower:
|
| 38 |
+
curve = cat_curve
|
| 39 |
+
confidence = "high"
|
| 40 |
+
break
|
| 41 |
+
|
| 42 |
+
monthly_units = interpolate_sales(bsr, curve)
|
| 43 |
+
return {"monthly_units": monthly_units, "confidence": confidence}
|
| 44 |
+
|
| 45 |
+
def calculate_opportunity_score(bsr, review_count, monthly_sales, seller_count) -> float:
|
| 46 |
+
score = 0
|
| 47 |
+
|
| 48 |
+
# Demand (30 pts)
|
| 49 |
+
if monthly_sales >= 3000: score += 30
|
| 50 |
+
elif monthly_sales >= 1500: score += 23
|
| 51 |
+
elif monthly_sales >= 800: score += 17
|
| 52 |
+
elif monthly_sales >= 300: score += 10
|
| 53 |
+
elif monthly_sales >= 100: score += 5
|
| 54 |
+
|
| 55 |
+
# Low competition reviews (30 pts)
|
| 56 |
+
if review_count < 100: score += 30
|
| 57 |
+
elif review_count < 500: score += 22
|
| 58 |
+
elif review_count < 1000: score += 15
|
| 59 |
+
elif review_count < 3000: score += 8
|
| 60 |
+
elif review_count < 8000: score += 3
|
| 61 |
+
|
| 62 |
+
# BSR strength (20 pts)
|
| 63 |
+
if bsr < 1000: score += 20
|
| 64 |
+
elif bsr < 5000: score += 15
|
| 65 |
+
elif bsr < 15000: score += 10
|
| 66 |
+
elif bsr < 50000: score += 5
|
| 67 |
+
|
| 68 |
+
# Seller count (20 pts)
|
| 69 |
+
if seller_count <= 1: score += 20
|
| 70 |
+
elif seller_count <= 3: score += 15
|
| 71 |
+
elif seller_count <= 8: score += 10
|
| 72 |
+
elif seller_count <= 15: score += 5
|
| 73 |
+
|
| 74 |
return round(min(100, score), 1)
|
app/services/amazon/scraper_utils.py
CHANGED
|
@@ -1,55 +1,55 @@
|
|
| 1 |
-
"""Shared HTTP/parsing helpers for Amazon scrapers (avoids circular imports)."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import random
|
| 5 |
-
import re
|
| 6 |
-
from typing import Optional
|
| 7 |
-
|
| 8 |
-
USER_AGENTS = [
|
| 9 |
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
| 10 |
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
| 11 |
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
| 12 |
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
| 13 |
-
]
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def get_headers() -> dict:
|
| 17 |
-
return {
|
| 18 |
-
"User-Agent": random.choice(USER_AGENTS),
|
| 19 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
| 20 |
-
"Accept-Language": "en-US,en;q=0.9",
|
| 21 |
-
"Accept-Encoding": "gzip, deflate, br",
|
| 22 |
-
"DNT": "1",
|
| 23 |
-
"Connection": "keep-alive",
|
| 24 |
-
"Upgrade-Insecure-Requests": "1",
|
| 25 |
-
"Sec-Fetch-Dest": "document",
|
| 26 |
-
"Sec-Fetch-Mode": "navigate",
|
| 27 |
-
"Sec-Fetch-Site": "none",
|
| 28 |
-
"Cache-Control": "max-age=0",
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def parse_number(text: str) -> Optional[float]:
|
| 33 |
-
if not text:
|
| 34 |
-
return None
|
| 35 |
-
cleaned = re.sub(r"[^\d.]", "", text.replace(",", ""))
|
| 36 |
-
try:
|
| 37 |
-
return float(cleaned) if cleaned else None
|
| 38 |
-
except ValueError:
|
| 39 |
-
return None
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def parse_int(text: str) -> Optional[int]:
|
| 43 |
-
val = parse_number(text)
|
| 44 |
-
return int(val) if val is not None else None
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def normalize_price(price: Optional[float]) -> Optional[float]:
|
| 48 |
-
"""Convert PKR to USD if price is too large."""
|
| 49 |
-
if price is None:
|
| 50 |
-
return None
|
| 51 |
-
if price > 500:
|
| 52 |
-
usd = round(price / 278, 2)
|
| 53 |
-
print(f"[scraper] Price {price} looks like PKR, converting to USD: ${usd}")
|
| 54 |
-
return usd
|
| 55 |
-
return price
|
|
|
|
| 1 |
+
"""Shared HTTP/parsing helpers for Amazon scrapers (avoids circular imports)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import random
|
| 5 |
+
import re
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
USER_AGENTS = [
|
| 9 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
| 10 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
| 11 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
| 12 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_headers() -> dict:
|
| 17 |
+
return {
|
| 18 |
+
"User-Agent": random.choice(USER_AGENTS),
|
| 19 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
| 20 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 21 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 22 |
+
"DNT": "1",
|
| 23 |
+
"Connection": "keep-alive",
|
| 24 |
+
"Upgrade-Insecure-Requests": "1",
|
| 25 |
+
"Sec-Fetch-Dest": "document",
|
| 26 |
+
"Sec-Fetch-Mode": "navigate",
|
| 27 |
+
"Sec-Fetch-Site": "none",
|
| 28 |
+
"Cache-Control": "max-age=0",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def parse_number(text: str) -> Optional[float]:
|
| 33 |
+
if not text:
|
| 34 |
+
return None
|
| 35 |
+
cleaned = re.sub(r"[^\d.]", "", text.replace(",", ""))
|
| 36 |
+
try:
|
| 37 |
+
return float(cleaned) if cleaned else None
|
| 38 |
+
except ValueError:
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def parse_int(text: str) -> Optional[int]:
|
| 43 |
+
val = parse_number(text)
|
| 44 |
+
return int(val) if val is not None else None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def normalize_price(price: Optional[float]) -> Optional[float]:
|
| 48 |
+
"""Convert PKR to USD if price is too large."""
|
| 49 |
+
if price is None:
|
| 50 |
+
return None
|
| 51 |
+
if price > 500:
|
| 52 |
+
usd = round(price / 278, 2)
|
| 53 |
+
print(f"[scraper] Price {price} looks like PKR, converting to USD: ${usd}")
|
| 54 |
+
return usd
|
| 55 |
+
return price
|
app/services/analytics/advanced_services.py
CHANGED
|
@@ -1,360 +1,360 @@
|
|
| 1 |
-
import re
|
| 2 |
-
import math
|
| 3 |
-
from typing import Optional
|
| 4 |
-
|
| 5 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 6 |
-
# 1. SALES ESTIMATOR v2 — category-specific BSR → monthly sales
|
| 7 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 8 |
-
|
| 9 |
-
CATEGORY_MULTIPLIERS = {
|
| 10 |
-
"kitchen": {"base": 8000, "decay": 0.55},
|
| 11 |
-
"home": {"base": 7000, "decay": 0.55},
|
| 12 |
-
"sports": {"base": 6000, "decay": 0.52},
|
| 13 |
-
"toys": {"base": 9000, "decay": 0.58},
|
| 14 |
-
"beauty": {"base": 7500, "decay": 0.54},
|
| 15 |
-
"health": {"base": 6500, "decay": 0.53},
|
| 16 |
-
"tools": {"base": 5000, "decay": 0.50},
|
| 17 |
-
"office": {"base": 5500, "decay": 0.51},
|
| 18 |
-
"pet": {"base": 6000, "decay": 0.52},
|
| 19 |
-
"garden": {"base": 4500, "decay": 0.50},
|
| 20 |
-
"default": {"base": 6000, "decay": 0.52},
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
def estimate_sales_v2(bsr: int, category: str = "default", price: float = 25.0) -> dict:
|
| 24 |
-
cat = CATEGORY_MULTIPLIERS.get(category.lower(), CATEGORY_MULTIPLIERS["default"])
|
| 25 |
-
if bsr <= 0:
|
| 26 |
-
bsr = 50000
|
| 27 |
-
monthly_sales = int(cat["base"] * math.pow(bsr, -cat["decay"]) * 1000)
|
| 28 |
-
monthly_sales = max(1, min(monthly_sales, 50000))
|
| 29 |
-
monthly_revenue = round(monthly_sales * price, 2)
|
| 30 |
-
daily_sales = round(monthly_sales / 30, 1)
|
| 31 |
-
|
| 32 |
-
# Confidence based on BSR range
|
| 33 |
-
if bsr < 5000:
|
| 34 |
-
confidence = "High"
|
| 35 |
-
elif bsr < 50000:
|
| 36 |
-
confidence = "Medium"
|
| 37 |
-
else:
|
| 38 |
-
confidence = "Low"
|
| 39 |
-
|
| 40 |
-
return {
|
| 41 |
-
"monthly_sales": monthly_sales,
|
| 42 |
-
"daily_sales": daily_sales,
|
| 43 |
-
"monthly_revenue": monthly_revenue,
|
| 44 |
-
"confidence": confidence,
|
| 45 |
-
"category": category,
|
| 46 |
-
"bsr": bsr,
|
| 47 |
-
"price": price,
|
| 48 |
-
"annual_revenue": round(monthly_revenue * 12, 2),
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 53 |
-
# 2. REVIEW ANALYZER — sentiment + patterns
|
| 54 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 55 |
-
|
| 56 |
-
POSITIVE_WORDS = ["great","excellent","perfect","love","amazing","best","good","quality","easy","fast","recommend","happy","pleased","works","nice","fantastic","awesome","solid","durable","comfortable"]
|
| 57 |
-
NEGATIVE_WORDS = ["poor","bad","terrible","worst","broken","useless","cheap","waste","disappointed","defective","fragile","flimsy","slow","wrong","missing","return","refund","fake","stopped","horrible"]
|
| 58 |
-
QUALITY_WORDS = ["quality","durable","sturdy","solid","premium","cheap","flimsy","fragile","well-made","poorly made"]
|
| 59 |
-
DELIVERY_WORDS = ["fast","quick","slow","delivery","shipping","arrived","late","early","package","damaged"]
|
| 60 |
-
VALUE_WORDS = ["worth","value","price","expensive","cheap","affordable","overpriced","deal","bargain"]
|
| 61 |
-
|
| 62 |
-
def analyze_reviews(rating: float, review_count: int, title: str = "") -> dict:
|
| 63 |
-
"""Analyze product based on available data — real NLP would need actual review text."""
|
| 64 |
-
|
| 65 |
-
# Star distribution estimate
|
| 66 |
-
if rating >= 4.5:
|
| 67 |
-
stars = {"5": 70, "4": 20, "3": 5, "2": 3, "1": 2}
|
| 68 |
-
elif rating >= 4.0:
|
| 69 |
-
stars = {"5": 55, "4": 25, "3": 10, "2": 5, "1": 5}
|
| 70 |
-
elif rating >= 3.5:
|
| 71 |
-
stars = {"5": 40, "4": 25, "3": 15, "2": 10, "1": 10}
|
| 72 |
-
elif rating >= 3.0:
|
| 73 |
-
stars = {"5": 30, "4": 20, "3": 20, "2": 15, "1": 15}
|
| 74 |
-
else:
|
| 75 |
-
stars = {"5": 20, "4": 15, "3": 15, "2": 20, "1": 30}
|
| 76 |
-
|
| 77 |
-
positive_pct = stars["5"] + stars["4"]
|
| 78 |
-
negative_pct = stars["1"] + stars["2"]
|
| 79 |
-
|
| 80 |
-
# Sentiment
|
| 81 |
-
if rating >= 4.3:
|
| 82 |
-
sentiment = "Very Positive"
|
| 83 |
-
sentiment_color = "#34d399"
|
| 84 |
-
elif rating >= 3.8:
|
| 85 |
-
sentiment = "Positive"
|
| 86 |
-
sentiment_color = "#86efac"
|
| 87 |
-
elif rating >= 3.3:
|
| 88 |
-
sentiment = "Mixed"
|
| 89 |
-
sentiment_color = "#fbbf24"
|
| 90 |
-
elif rating >= 2.8:
|
| 91 |
-
sentiment = "Negative"
|
| 92 |
-
sentiment_color = "#fb923c"
|
| 93 |
-
else:
|
| 94 |
-
sentiment = "Very Negative"
|
| 95 |
-
sentiment_color = "#f87171"
|
| 96 |
-
|
| 97 |
-
# Review velocity
|
| 98 |
-
if review_count > 10000:
|
| 99 |
-
velocity = "Saturated"
|
| 100 |
-
elif review_count > 1000:
|
| 101 |
-
velocity = "High"
|
| 102 |
-
elif review_count > 200:
|
| 103 |
-
velocity = "Medium"
|
| 104 |
-
elif review_count > 50:
|
| 105 |
-
velocity = "Growing"
|
| 106 |
-
else:
|
| 107 |
-
velocity = "Low"
|
| 108 |
-
|
| 109 |
-
# Market opportunity from reviews
|
| 110 |
-
if review_count < 100 and rating >= 4.0:
|
| 111 |
-
opportunity = "High — low reviews, good rating. Easy entry."
|
| 112 |
-
elif review_count < 500 and rating >= 3.8:
|
| 113 |
-
opportunity = "Medium — manageable competition."
|
| 114 |
-
elif review_count > 1000 and rating < 3.8:
|
| 115 |
-
opportunity = "Medium — saturated but poor quality. Can disrupt."
|
| 116 |
-
elif review_count > 1000 and rating >= 4.2:
|
| 117 |
-
opportunity = "Low — strong competition, hard to enter."
|
| 118 |
-
else:
|
| 119 |
-
opportunity = "Medium — assess further."
|
| 120 |
-
|
| 121 |
-
return {
|
| 122 |
-
"rating": rating,
|
| 123 |
-
"review_count": review_count,
|
| 124 |
-
"sentiment": sentiment,
|
| 125 |
-
"sentiment_color": sentiment_color,
|
| 126 |
-
"positive_pct": positive_pct,
|
| 127 |
-
"negative_pct": negative_pct,
|
| 128 |
-
"star_distribution": stars,
|
| 129 |
-
"review_velocity": velocity,
|
| 130 |
-
"market_opportunity": opportunity,
|
| 131 |
-
"common_positives": ["Good quality", "Fast delivery", "Value for money", "Easy to use", "As described"][:3],
|
| 132 |
-
"common_negatives": ["Quality issues", "Poor packaging", "Not as described"][:2] if negative_pct > 15 else [],
|
| 133 |
-
"entry_barrier": "Low" if review_count < 100 else "Medium" if review_count < 500 else "High",
|
| 134 |
-
"entry_barrier_color": "#34d399" if review_count < 100 else "#fbbf24" if review_count < 500 else "#f87171",
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 139 |
-
# 3. BUY BOX ANALYZER
|
| 140 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 141 |
-
|
| 142 |
-
def analyze_buy_box(price: float, rating: float, review_count: int, is_fba: bool = True, is_prime: bool = True) -> dict:
|
| 143 |
-
score = 0
|
| 144 |
-
factors = []
|
| 145 |
-
|
| 146 |
-
# Price competitiveness
|
| 147 |
-
if price < 20:
|
| 148 |
-
score += 25
|
| 149 |
-
factors.append({"factor": "Price", "status": "Competitive", "color": "#34d399", "detail": f"${price:.2f} — low price helps win buy box"})
|
| 150 |
-
elif price < 50:
|
| 151 |
-
score += 20
|
| 152 |
-
factors.append({"factor": "Price", "status": "Moderate", "color": "#fbbf24", "detail": f"${price:.2f} — average price range"})
|
| 153 |
-
else:
|
| 154 |
-
score += 10
|
| 155 |
-
factors.append({"factor": "Price", "status": "High", "color": "#f87171", "detail": f"${price:.2f} — high price hurts buy box chances"})
|
| 156 |
-
|
| 157 |
-
# FBA
|
| 158 |
-
if is_fba:
|
| 159 |
-
score += 30
|
| 160 |
-
factors.append({"factor": "Fulfillment", "status": "FBA ✓", "color": "#34d399", "detail": "FBA strongly favored for buy box"})
|
| 161 |
-
else:
|
| 162 |
-
score += 5
|
| 163 |
-
factors.append({"factor": "Fulfillment", "status": "FBM", "color": "#f87171", "detail": "FBM sellers rarely win buy box"})
|
| 164 |
-
|
| 165 |
-
# Prime
|
| 166 |
-
if is_prime:
|
| 167 |
-
score += 20
|
| 168 |
-
factors.append({"factor": "Prime", "status": "Eligible ✓", "color": "#34d399", "detail": "Prime eligibility required for buy box"})
|
| 169 |
-
else:
|
| 170 |
-
score += 0
|
| 171 |
-
factors.append({"factor": "Prime", "status": "Not Eligible", "color": "#f87171", "detail": "No Prime = very low buy box chance"})
|
| 172 |
-
|
| 173 |
-
# Seller rating
|
| 174 |
-
if rating >= 4.5:
|
| 175 |
-
score += 15
|
| 176 |
-
factors.append({"factor": "Seller Rating", "status": "Excellent", "color": "#34d399", "detail": f"{rating}★ — Amazon favors high-rated sellers"})
|
| 177 |
-
elif rating >= 4.0:
|
| 178 |
-
score += 10
|
| 179 |
-
factors.append({"factor": "Seller Rating", "status": "Good", "color": "#fbbf24", "detail": f"{rating}★ — acceptable for buy box"})
|
| 180 |
-
else:
|
| 181 |
-
score += 3
|
| 182 |
-
factors.append({"factor": "Seller Rating", "status": "Needs Work", "color": "#f87171", "detail": f"{rating}★ — low rating hurts buy box"})
|
| 183 |
-
|
| 184 |
-
# Review count
|
| 185 |
-
if review_count > 100:
|
| 186 |
-
score += 10
|
| 187 |
-
factors.append({"factor": "Reviews", "status": "Established", "color": "#34d399", "detail": f"{review_count:,} reviews — trusted seller signal"})
|
| 188 |
-
else:
|
| 189 |
-
score += 5
|
| 190 |
-
factors.append({"factor": "Reviews", "status": "New Seller", "color": "#fbbf24", "detail": f"{review_count} reviews — building trust"})
|
| 191 |
-
|
| 192 |
-
score = min(score, 100)
|
| 193 |
-
|
| 194 |
-
if score >= 75:
|
| 195 |
-
verdict = "Strong Buy Box Candidate"
|
| 196 |
-
verdict_color = "#34d399"
|
| 197 |
-
elif score >= 50:
|
| 198 |
-
verdict = "Moderate Chance"
|
| 199 |
-
verdict_color = "#fbbf24"
|
| 200 |
-
elif score >= 25:
|
| 201 |
-
verdict = "Low Chance"
|
| 202 |
-
verdict_color = "#fb923c"
|
| 203 |
-
else:
|
| 204 |
-
verdict = "Unlikely to Win"
|
| 205 |
-
verdict_color = "#f87171"
|
| 206 |
-
|
| 207 |
-
return {
|
| 208 |
-
"score": score,
|
| 209 |
-
"verdict": verdict,
|
| 210 |
-
"verdict_color": verdict_color,
|
| 211 |
-
"factors": factors,
|
| 212 |
-
"tips": [
|
| 213 |
-
"Use FBA for best buy box eligibility",
|
| 214 |
-
"Price within 2% of lowest FBA offer",
|
| 215 |
-
"Maintain 95%+ seller feedback rating",
|
| 216 |
-
"Keep order defect rate below 1%",
|
| 217 |
-
"Ship on time — late shipments hurt score",
|
| 218 |
-
][:3]
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 223 |
-
# 4. NICHE FINDER
|
| 224 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 225 |
-
|
| 226 |
-
def analyze_niche(keyword: str, avg_bsr: float, avg_reviews: float, avg_price: float, product_count: int = 100) -> dict:
|
| 227 |
-
# Demand score (lower BSR = higher demand)
|
| 228 |
-
if avg_bsr < 5000:
|
| 229 |
-
demand = 95
|
| 230 |
-
elif avg_bsr < 20000:
|
| 231 |
-
demand = 80
|
| 232 |
-
elif avg_bsr < 50000:
|
| 233 |
-
demand = 60
|
| 234 |
-
elif avg_bsr < 100000:
|
| 235 |
-
demand = 40
|
| 236 |
-
else:
|
| 237 |
-
demand = 20
|
| 238 |
-
|
| 239 |
-
# Competition score (lower reviews = less competition)
|
| 240 |
-
if avg_reviews < 50:
|
| 241 |
-
competition = 10 # low competition
|
| 242 |
-
elif avg_reviews < 200:
|
| 243 |
-
competition = 30
|
| 244 |
-
elif avg_reviews < 500:
|
| 245 |
-
competition = 55
|
| 246 |
-
elif avg_reviews < 1000:
|
| 247 |
-
competition = 75
|
| 248 |
-
else:
|
| 249 |
-
competition = 90
|
| 250 |
-
|
| 251 |
-
# Price attractiveness
|
| 252 |
-
if 20 <= avg_price <= 60:
|
| 253 |
-
price_score = 90
|
| 254 |
-
price_note = "Sweet spot for FBA ($20-$60)"
|
| 255 |
-
elif 10 <= avg_price < 20:
|
| 256 |
-
price_score = 60
|
| 257 |
-
price_note = "Low margin risk under $20"
|
| 258 |
-
elif 60 < avg_price <= 100:
|
| 259 |
-
price_score = 75
|
| 260 |
-
price_note = "Good margin, lower volume"
|
| 261 |
-
else:
|
| 262 |
-
price_score = 40
|
| 263 |
-
price_note = "High price = niche market"
|
| 264 |
-
|
| 265 |
-
# Opportunity score
|
| 266 |
-
opportunity = round((demand * 0.4) + ((100 - competition) * 0.4) + (price_score * 0.2))
|
| 267 |
-
|
| 268 |
-
if opportunity >= 70:
|
| 269 |
-
verdict = "🟢 Great Niche"
|
| 270 |
-
verdict_color = "#34d399"
|
| 271 |
-
elif opportunity >= 50:
|
| 272 |
-
verdict = "🟡 Decent Niche"
|
| 273 |
-
verdict_color = "#fbbf24"
|
| 274 |
-
elif opportunity >= 35:
|
| 275 |
-
verdict = "🟠 Challenging"
|
| 276 |
-
verdict_color = "#fb923c"
|
| 277 |
-
else:
|
| 278 |
-
verdict = "🔴 Avoid"
|
| 279 |
-
verdict_color = "#f87171"
|
| 280 |
-
|
| 281 |
-
est_monthly_sales = estimate_sales_v2(int(avg_bsr), price=avg_price)["monthly_sales"]
|
| 282 |
-
market_size = round(est_monthly_sales * avg_price * product_count / 1000, 0)
|
| 283 |
-
|
| 284 |
-
return {
|
| 285 |
-
"keyword": keyword,
|
| 286 |
-
"opportunity_score": opportunity,
|
| 287 |
-
"verdict": verdict,
|
| 288 |
-
"verdict_color": verdict_color,
|
| 289 |
-
"demand_score": demand,
|
| 290 |
-
"competition_score": competition,
|
| 291 |
-
"price_score": price_score,
|
| 292 |
-
"price_note": price_note,
|
| 293 |
-
"avg_bsr": int(avg_bsr),
|
| 294 |
-
"avg_reviews": int(avg_reviews),
|
| 295 |
-
"avg_price": avg_price,
|
| 296 |
-
"est_monthly_sales": est_monthly_sales,
|
| 297 |
-
"market_size_k": market_size,
|
| 298 |
-
"recommendation": (
|
| 299 |
-
"Low competition — great entry opportunity!" if competition < 30
|
| 300 |
-
else "Medium competition — differentiate product." if competition < 60
|
| 301 |
-
else "High competition — need strong differentiation."
|
| 302 |
-
)
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 307 |
-
# 5. KEYWORD RESEARCH
|
| 308 |
-
# ══════════════════════════════════════════════════════════════════════════════
|
| 309 |
-
|
| 310 |
-
def generate_keywords(seed_keyword: str, category: str = "") -> dict:
|
| 311 |
-
seed = seed_keyword.lower().strip()
|
| 312 |
-
words = seed.split()
|
| 313 |
-
|
| 314 |
-
# Generate variations
|
| 315 |
-
modifiers_prefix = ["best", "top", "premium", "cheap", "professional", "heavy duty", "large", "small", "portable", "electric"]
|
| 316 |
-
modifiers_suffix = ["for home", "for kitchen", "for office", "set", "kit", "bundle", "with lid", "non stick", "stainless steel", "organic"]
|
| 317 |
-
|
| 318 |
-
keywords = []
|
| 319 |
-
|
| 320 |
-
# Main keyword
|
| 321 |
-
keywords.append({
|
| 322 |
-
"keyword": seed,
|
| 323 |
-
"search_volume": "High",
|
| 324 |
-
"competition": "High",
|
| 325 |
-
"opportunity": "Low",
|
| 326 |
-
"type": "Head"
|
| 327 |
-
})
|
| 328 |
-
|
| 329 |
-
# Long tail variations
|
| 330 |
-
for mod in modifiers_prefix[:5]:
|
| 331 |
-
kw = f"{mod} {seed}"
|
| 332 |
-
keywords.append({
|
| 333 |
-
"keyword": kw,
|
| 334 |
-
"search_volume": "Medium",
|
| 335 |
-
"competition": "Medium",
|
| 336 |
-
"opportunity": "Medium",
|
| 337 |
-
"type": "Long-tail"
|
| 338 |
-
})
|
| 339 |
-
|
| 340 |
-
for mod in modifiers_suffix[:5]:
|
| 341 |
-
kw = f"{seed} {mod}"
|
| 342 |
-
keywords.append({
|
| 343 |
-
"keyword": kw,
|
| 344 |
-
"search_volume": "Low",
|
| 345 |
-
"competition": "Low",
|
| 346 |
-
"opportunity": "High",
|
| 347 |
-
"type": "Long-tail"
|
| 348 |
-
})
|
| 349 |
-
|
| 350 |
-
# Backend keywords (for listing)
|
| 351 |
-
backend = [seed] + words + [f"{words[-1]} set", f"buy {seed}", f"{seed} amazon"]
|
| 352 |
-
|
| 353 |
-
return {
|
| 354 |
-
"seed_keyword": seed,
|
| 355 |
-
"total_keywords": len(keywords),
|
| 356 |
-
"keywords": keywords,
|
| 357 |
-
"backend_keywords": backend,
|
| 358 |
-
"top_opportunity": [k for k in keywords if k["opportunity"] == "High"][:3],
|
| 359 |
-
"tip": f"Target '{seed} for home' or '{seed} set' — lower competition, high buyer intent."
|
| 360 |
}
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import math
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 6 |
+
# 1. SALES ESTIMATOR v2 — category-specific BSR → monthly sales
|
| 7 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 8 |
+
|
| 9 |
+
CATEGORY_MULTIPLIERS = {
|
| 10 |
+
"kitchen": {"base": 8000, "decay": 0.55},
|
| 11 |
+
"home": {"base": 7000, "decay": 0.55},
|
| 12 |
+
"sports": {"base": 6000, "decay": 0.52},
|
| 13 |
+
"toys": {"base": 9000, "decay": 0.58},
|
| 14 |
+
"beauty": {"base": 7500, "decay": 0.54},
|
| 15 |
+
"health": {"base": 6500, "decay": 0.53},
|
| 16 |
+
"tools": {"base": 5000, "decay": 0.50},
|
| 17 |
+
"office": {"base": 5500, "decay": 0.51},
|
| 18 |
+
"pet": {"base": 6000, "decay": 0.52},
|
| 19 |
+
"garden": {"base": 4500, "decay": 0.50},
|
| 20 |
+
"default": {"base": 6000, "decay": 0.52},
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
def estimate_sales_v2(bsr: int, category: str = "default", price: float = 25.0) -> dict:
|
| 24 |
+
cat = CATEGORY_MULTIPLIERS.get(category.lower(), CATEGORY_MULTIPLIERS["default"])
|
| 25 |
+
if bsr <= 0:
|
| 26 |
+
bsr = 50000
|
| 27 |
+
monthly_sales = int(cat["base"] * math.pow(bsr, -cat["decay"]) * 1000)
|
| 28 |
+
monthly_sales = max(1, min(monthly_sales, 50000))
|
| 29 |
+
monthly_revenue = round(monthly_sales * price, 2)
|
| 30 |
+
daily_sales = round(monthly_sales / 30, 1)
|
| 31 |
+
|
| 32 |
+
# Confidence based on BSR range
|
| 33 |
+
if bsr < 5000:
|
| 34 |
+
confidence = "High"
|
| 35 |
+
elif bsr < 50000:
|
| 36 |
+
confidence = "Medium"
|
| 37 |
+
else:
|
| 38 |
+
confidence = "Low"
|
| 39 |
+
|
| 40 |
+
return {
|
| 41 |
+
"monthly_sales": monthly_sales,
|
| 42 |
+
"daily_sales": daily_sales,
|
| 43 |
+
"monthly_revenue": monthly_revenue,
|
| 44 |
+
"confidence": confidence,
|
| 45 |
+
"category": category,
|
| 46 |
+
"bsr": bsr,
|
| 47 |
+
"price": price,
|
| 48 |
+
"annual_revenue": round(monthly_revenue * 12, 2),
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 53 |
+
# 2. REVIEW ANALYZER — sentiment + patterns
|
| 54 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 55 |
+
|
| 56 |
+
POSITIVE_WORDS = ["great","excellent","perfect","love","amazing","best","good","quality","easy","fast","recommend","happy","pleased","works","nice","fantastic","awesome","solid","durable","comfortable"]
|
| 57 |
+
NEGATIVE_WORDS = ["poor","bad","terrible","worst","broken","useless","cheap","waste","disappointed","defective","fragile","flimsy","slow","wrong","missing","return","refund","fake","stopped","horrible"]
|
| 58 |
+
QUALITY_WORDS = ["quality","durable","sturdy","solid","premium","cheap","flimsy","fragile","well-made","poorly made"]
|
| 59 |
+
DELIVERY_WORDS = ["fast","quick","slow","delivery","shipping","arrived","late","early","package","damaged"]
|
| 60 |
+
VALUE_WORDS = ["worth","value","price","expensive","cheap","affordable","overpriced","deal","bargain"]
|
| 61 |
+
|
| 62 |
+
def analyze_reviews(rating: float, review_count: int, title: str = "") -> dict:
|
| 63 |
+
"""Analyze product based on available data — real NLP would need actual review text."""
|
| 64 |
+
|
| 65 |
+
# Star distribution estimate
|
| 66 |
+
if rating >= 4.5:
|
| 67 |
+
stars = {"5": 70, "4": 20, "3": 5, "2": 3, "1": 2}
|
| 68 |
+
elif rating >= 4.0:
|
| 69 |
+
stars = {"5": 55, "4": 25, "3": 10, "2": 5, "1": 5}
|
| 70 |
+
elif rating >= 3.5:
|
| 71 |
+
stars = {"5": 40, "4": 25, "3": 15, "2": 10, "1": 10}
|
| 72 |
+
elif rating >= 3.0:
|
| 73 |
+
stars = {"5": 30, "4": 20, "3": 20, "2": 15, "1": 15}
|
| 74 |
+
else:
|
| 75 |
+
stars = {"5": 20, "4": 15, "3": 15, "2": 20, "1": 30}
|
| 76 |
+
|
| 77 |
+
positive_pct = stars["5"] + stars["4"]
|
| 78 |
+
negative_pct = stars["1"] + stars["2"]
|
| 79 |
+
|
| 80 |
+
# Sentiment
|
| 81 |
+
if rating >= 4.3:
|
| 82 |
+
sentiment = "Very Positive"
|
| 83 |
+
sentiment_color = "#34d399"
|
| 84 |
+
elif rating >= 3.8:
|
| 85 |
+
sentiment = "Positive"
|
| 86 |
+
sentiment_color = "#86efac"
|
| 87 |
+
elif rating >= 3.3:
|
| 88 |
+
sentiment = "Mixed"
|
| 89 |
+
sentiment_color = "#fbbf24"
|
| 90 |
+
elif rating >= 2.8:
|
| 91 |
+
sentiment = "Negative"
|
| 92 |
+
sentiment_color = "#fb923c"
|
| 93 |
+
else:
|
| 94 |
+
sentiment = "Very Negative"
|
| 95 |
+
sentiment_color = "#f87171"
|
| 96 |
+
|
| 97 |
+
# Review velocity
|
| 98 |
+
if review_count > 10000:
|
| 99 |
+
velocity = "Saturated"
|
| 100 |
+
elif review_count > 1000:
|
| 101 |
+
velocity = "High"
|
| 102 |
+
elif review_count > 200:
|
| 103 |
+
velocity = "Medium"
|
| 104 |
+
elif review_count > 50:
|
| 105 |
+
velocity = "Growing"
|
| 106 |
+
else:
|
| 107 |
+
velocity = "Low"
|
| 108 |
+
|
| 109 |
+
# Market opportunity from reviews
|
| 110 |
+
if review_count < 100 and rating >= 4.0:
|
| 111 |
+
opportunity = "High — low reviews, good rating. Easy entry."
|
| 112 |
+
elif review_count < 500 and rating >= 3.8:
|
| 113 |
+
opportunity = "Medium — manageable competition."
|
| 114 |
+
elif review_count > 1000 and rating < 3.8:
|
| 115 |
+
opportunity = "Medium — saturated but poor quality. Can disrupt."
|
| 116 |
+
elif review_count > 1000 and rating >= 4.2:
|
| 117 |
+
opportunity = "Low — strong competition, hard to enter."
|
| 118 |
+
else:
|
| 119 |
+
opportunity = "Medium — assess further."
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"rating": rating,
|
| 123 |
+
"review_count": review_count,
|
| 124 |
+
"sentiment": sentiment,
|
| 125 |
+
"sentiment_color": sentiment_color,
|
| 126 |
+
"positive_pct": positive_pct,
|
| 127 |
+
"negative_pct": negative_pct,
|
| 128 |
+
"star_distribution": stars,
|
| 129 |
+
"review_velocity": velocity,
|
| 130 |
+
"market_opportunity": opportunity,
|
| 131 |
+
"common_positives": ["Good quality", "Fast delivery", "Value for money", "Easy to use", "As described"][:3],
|
| 132 |
+
"common_negatives": ["Quality issues", "Poor packaging", "Not as described"][:2] if negative_pct > 15 else [],
|
| 133 |
+
"entry_barrier": "Low" if review_count < 100 else "Medium" if review_count < 500 else "High",
|
| 134 |
+
"entry_barrier_color": "#34d399" if review_count < 100 else "#fbbf24" if review_count < 500 else "#f87171",
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 139 |
+
# 3. BUY BOX ANALYZER
|
| 140 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 141 |
+
|
| 142 |
+
def analyze_buy_box(price: float, rating: float, review_count: int, is_fba: bool = True, is_prime: bool = True) -> dict:
|
| 143 |
+
score = 0
|
| 144 |
+
factors = []
|
| 145 |
+
|
| 146 |
+
# Price competitiveness
|
| 147 |
+
if price < 20:
|
| 148 |
+
score += 25
|
| 149 |
+
factors.append({"factor": "Price", "status": "Competitive", "color": "#34d399", "detail": f"${price:.2f} — low price helps win buy box"})
|
| 150 |
+
elif price < 50:
|
| 151 |
+
score += 20
|
| 152 |
+
factors.append({"factor": "Price", "status": "Moderate", "color": "#fbbf24", "detail": f"${price:.2f} — average price range"})
|
| 153 |
+
else:
|
| 154 |
+
score += 10
|
| 155 |
+
factors.append({"factor": "Price", "status": "High", "color": "#f87171", "detail": f"${price:.2f} — high price hurts buy box chances"})
|
| 156 |
+
|
| 157 |
+
# FBA
|
| 158 |
+
if is_fba:
|
| 159 |
+
score += 30
|
| 160 |
+
factors.append({"factor": "Fulfillment", "status": "FBA ✓", "color": "#34d399", "detail": "FBA strongly favored for buy box"})
|
| 161 |
+
else:
|
| 162 |
+
score += 5
|
| 163 |
+
factors.append({"factor": "Fulfillment", "status": "FBM", "color": "#f87171", "detail": "FBM sellers rarely win buy box"})
|
| 164 |
+
|
| 165 |
+
# Prime
|
| 166 |
+
if is_prime:
|
| 167 |
+
score += 20
|
| 168 |
+
factors.append({"factor": "Prime", "status": "Eligible ✓", "color": "#34d399", "detail": "Prime eligibility required for buy box"})
|
| 169 |
+
else:
|
| 170 |
+
score += 0
|
| 171 |
+
factors.append({"factor": "Prime", "status": "Not Eligible", "color": "#f87171", "detail": "No Prime = very low buy box chance"})
|
| 172 |
+
|
| 173 |
+
# Seller rating
|
| 174 |
+
if rating >= 4.5:
|
| 175 |
+
score += 15
|
| 176 |
+
factors.append({"factor": "Seller Rating", "status": "Excellent", "color": "#34d399", "detail": f"{rating}★ — Amazon favors high-rated sellers"})
|
| 177 |
+
elif rating >= 4.0:
|
| 178 |
+
score += 10
|
| 179 |
+
factors.append({"factor": "Seller Rating", "status": "Good", "color": "#fbbf24", "detail": f"{rating}★ — acceptable for buy box"})
|
| 180 |
+
else:
|
| 181 |
+
score += 3
|
| 182 |
+
factors.append({"factor": "Seller Rating", "status": "Needs Work", "color": "#f87171", "detail": f"{rating}★ — low rating hurts buy box"})
|
| 183 |
+
|
| 184 |
+
# Review count
|
| 185 |
+
if review_count > 100:
|
| 186 |
+
score += 10
|
| 187 |
+
factors.append({"factor": "Reviews", "status": "Established", "color": "#34d399", "detail": f"{review_count:,} reviews — trusted seller signal"})
|
| 188 |
+
else:
|
| 189 |
+
score += 5
|
| 190 |
+
factors.append({"factor": "Reviews", "status": "New Seller", "color": "#fbbf24", "detail": f"{review_count} reviews — building trust"})
|
| 191 |
+
|
| 192 |
+
score = min(score, 100)
|
| 193 |
+
|
| 194 |
+
if score >= 75:
|
| 195 |
+
verdict = "Strong Buy Box Candidate"
|
| 196 |
+
verdict_color = "#34d399"
|
| 197 |
+
elif score >= 50:
|
| 198 |
+
verdict = "Moderate Chance"
|
| 199 |
+
verdict_color = "#fbbf24"
|
| 200 |
+
elif score >= 25:
|
| 201 |
+
verdict = "Low Chance"
|
| 202 |
+
verdict_color = "#fb923c"
|
| 203 |
+
else:
|
| 204 |
+
verdict = "Unlikely to Win"
|
| 205 |
+
verdict_color = "#f87171"
|
| 206 |
+
|
| 207 |
+
return {
|
| 208 |
+
"score": score,
|
| 209 |
+
"verdict": verdict,
|
| 210 |
+
"verdict_color": verdict_color,
|
| 211 |
+
"factors": factors,
|
| 212 |
+
"tips": [
|
| 213 |
+
"Use FBA for best buy box eligibility",
|
| 214 |
+
"Price within 2% of lowest FBA offer",
|
| 215 |
+
"Maintain 95%+ seller feedback rating",
|
| 216 |
+
"Keep order defect rate below 1%",
|
| 217 |
+
"Ship on time — late shipments hurt score",
|
| 218 |
+
][:3]
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 223 |
+
# 4. NICHE FINDER
|
| 224 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 225 |
+
|
| 226 |
+
def analyze_niche(keyword: str, avg_bsr: float, avg_reviews: float, avg_price: float, product_count: int = 100) -> dict:
|
| 227 |
+
# Demand score (lower BSR = higher demand)
|
| 228 |
+
if avg_bsr < 5000:
|
| 229 |
+
demand = 95
|
| 230 |
+
elif avg_bsr < 20000:
|
| 231 |
+
demand = 80
|
| 232 |
+
elif avg_bsr < 50000:
|
| 233 |
+
demand = 60
|
| 234 |
+
elif avg_bsr < 100000:
|
| 235 |
+
demand = 40
|
| 236 |
+
else:
|
| 237 |
+
demand = 20
|
| 238 |
+
|
| 239 |
+
# Competition score (lower reviews = less competition)
|
| 240 |
+
if avg_reviews < 50:
|
| 241 |
+
competition = 10 # low competition
|
| 242 |
+
elif avg_reviews < 200:
|
| 243 |
+
competition = 30
|
| 244 |
+
elif avg_reviews < 500:
|
| 245 |
+
competition = 55
|
| 246 |
+
elif avg_reviews < 1000:
|
| 247 |
+
competition = 75
|
| 248 |
+
else:
|
| 249 |
+
competition = 90
|
| 250 |
+
|
| 251 |
+
# Price attractiveness
|
| 252 |
+
if 20 <= avg_price <= 60:
|
| 253 |
+
price_score = 90
|
| 254 |
+
price_note = "Sweet spot for FBA ($20-$60)"
|
| 255 |
+
elif 10 <= avg_price < 20:
|
| 256 |
+
price_score = 60
|
| 257 |
+
price_note = "Low margin risk under $20"
|
| 258 |
+
elif 60 < avg_price <= 100:
|
| 259 |
+
price_score = 75
|
| 260 |
+
price_note = "Good margin, lower volume"
|
| 261 |
+
else:
|
| 262 |
+
price_score = 40
|
| 263 |
+
price_note = "High price = niche market"
|
| 264 |
+
|
| 265 |
+
# Opportunity score
|
| 266 |
+
opportunity = round((demand * 0.4) + ((100 - competition) * 0.4) + (price_score * 0.2))
|
| 267 |
+
|
| 268 |
+
if opportunity >= 70:
|
| 269 |
+
verdict = "🟢 Great Niche"
|
| 270 |
+
verdict_color = "#34d399"
|
| 271 |
+
elif opportunity >= 50:
|
| 272 |
+
verdict = "🟡 Decent Niche"
|
| 273 |
+
verdict_color = "#fbbf24"
|
| 274 |
+
elif opportunity >= 35:
|
| 275 |
+
verdict = "🟠 Challenging"
|
| 276 |
+
verdict_color = "#fb923c"
|
| 277 |
+
else:
|
| 278 |
+
verdict = "🔴 Avoid"
|
| 279 |
+
verdict_color = "#f87171"
|
| 280 |
+
|
| 281 |
+
est_monthly_sales = estimate_sales_v2(int(avg_bsr), price=avg_price)["monthly_sales"]
|
| 282 |
+
market_size = round(est_monthly_sales * avg_price * product_count / 1000, 0)
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
"keyword": keyword,
|
| 286 |
+
"opportunity_score": opportunity,
|
| 287 |
+
"verdict": verdict,
|
| 288 |
+
"verdict_color": verdict_color,
|
| 289 |
+
"demand_score": demand,
|
| 290 |
+
"competition_score": competition,
|
| 291 |
+
"price_score": price_score,
|
| 292 |
+
"price_note": price_note,
|
| 293 |
+
"avg_bsr": int(avg_bsr),
|
| 294 |
+
"avg_reviews": int(avg_reviews),
|
| 295 |
+
"avg_price": avg_price,
|
| 296 |
+
"est_monthly_sales": est_monthly_sales,
|
| 297 |
+
"market_size_k": market_size,
|
| 298 |
+
"recommendation": (
|
| 299 |
+
"Low competition — great entry opportunity!" if competition < 30
|
| 300 |
+
else "Medium competition — differentiate product." if competition < 60
|
| 301 |
+
else "High competition — need strong differentiation."
|
| 302 |
+
)
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 307 |
+
# 5. KEYWORD RESEARCH
|
| 308 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 309 |
+
|
| 310 |
+
def generate_keywords(seed_keyword: str, category: str = "") -> dict:
|
| 311 |
+
seed = seed_keyword.lower().strip()
|
| 312 |
+
words = seed.split()
|
| 313 |
+
|
| 314 |
+
# Generate variations
|
| 315 |
+
modifiers_prefix = ["best", "top", "premium", "cheap", "professional", "heavy duty", "large", "small", "portable", "electric"]
|
| 316 |
+
modifiers_suffix = ["for home", "for kitchen", "for office", "set", "kit", "bundle", "with lid", "non stick", "stainless steel", "organic"]
|
| 317 |
+
|
| 318 |
+
keywords = []
|
| 319 |
+
|
| 320 |
+
# Main keyword
|
| 321 |
+
keywords.append({
|
| 322 |
+
"keyword": seed,
|
| 323 |
+
"search_volume": "High",
|
| 324 |
+
"competition": "High",
|
| 325 |
+
"opportunity": "Low",
|
| 326 |
+
"type": "Head"
|
| 327 |
+
})
|
| 328 |
+
|
| 329 |
+
# Long tail variations
|
| 330 |
+
for mod in modifiers_prefix[:5]:
|
| 331 |
+
kw = f"{mod} {seed}"
|
| 332 |
+
keywords.append({
|
| 333 |
+
"keyword": kw,
|
| 334 |
+
"search_volume": "Medium",
|
| 335 |
+
"competition": "Medium",
|
| 336 |
+
"opportunity": "Medium",
|
| 337 |
+
"type": "Long-tail"
|
| 338 |
+
})
|
| 339 |
+
|
| 340 |
+
for mod in modifiers_suffix[:5]:
|
| 341 |
+
kw = f"{seed} {mod}"
|
| 342 |
+
keywords.append({
|
| 343 |
+
"keyword": kw,
|
| 344 |
+
"search_volume": "Low",
|
| 345 |
+
"competition": "Low",
|
| 346 |
+
"opportunity": "High",
|
| 347 |
+
"type": "Long-tail"
|
| 348 |
+
})
|
| 349 |
+
|
| 350 |
+
# Backend keywords (for listing)
|
| 351 |
+
backend = [seed] + words + [f"{words[-1]} set", f"buy {seed}", f"{seed} amazon"]
|
| 352 |
+
|
| 353 |
+
return {
|
| 354 |
+
"seed_keyword": seed,
|
| 355 |
+
"total_keywords": len(keywords),
|
| 356 |
+
"keywords": keywords,
|
| 357 |
+
"backend_keywords": backend,
|
| 358 |
+
"top_opportunity": [k for k in keywords if k["opportunity"] == "High"][:3],
|
| 359 |
+
"tip": f"Target '{seed} for home' or '{seed} set' — lower competition, high buyer intent."
|
| 360 |
}
|
app/services/analytics/ai_analyzer.py
CHANGED
|
@@ -1,188 +1,188 @@
|
|
| 1 |
-
"""AI product analysis — LLM-powered with heuristic fallback."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
from typing import Any, Dict, List
|
| 5 |
-
|
| 6 |
-
from app.services.llm_service import generate_json, get_active_provider, is_llm_available
|
| 7 |
-
|
| 8 |
-
AI_ANALYSIS_SYSTEM = (
|
| 9 |
-
"You are Rankora AI, an expert Amazon FBA product research analyst. "
|
| 10 |
-
"Given structured product metrics, produce a JSON analysis for sellers deciding "
|
| 11 |
-
"whether to source and launch this product. Be specific, honest, and actionable. "
|
| 12 |
-
"Use ONLY the data provided — do not invent sales figures."
|
| 13 |
-
)
|
| 14 |
-
|
| 15 |
-
AI_ANALYSIS_SCHEMA = """
|
| 16 |
-
{
|
| 17 |
-
"overall_signal": "emoji + short label e.g. Strong Buy Signal",
|
| 18 |
-
"summary": "2-3 sentence executive summary",
|
| 19 |
-
"action": "one clear next step",
|
| 20 |
-
"market_position": "Top Seller | Strong Performer | etc.",
|
| 21 |
-
"position_insight": "1-2 sentences",
|
| 22 |
-
"review_barrier": "Low | Medium | High | Very High",
|
| 23 |
-
"review_insight": "1-2 sentences",
|
| 24 |
-
"price_insight": "1-2 sentences",
|
| 25 |
-
"pricing_strategy": "actionable pricing advice",
|
| 26 |
-
"rating_insight": "1-2 sentences",
|
| 27 |
-
"quality_bar": "Low | Medium | High | Very High",
|
| 28 |
-
"recommendations": ["bullet 1", "bullet 2", "... up to 6"],
|
| 29 |
-
"llm_insights": ["deeper insight 1", "deeper insight 2", "... up to 4"],
|
| 30 |
-
"risk_factors": ["risk 1", "risk 2"],
|
| 31 |
-
"competitive_moat": "what protects incumbents or what gap exists"
|
| 32 |
-
}
|
| 33 |
-
"""
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def _heuristic_analysis(product_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 37 |
-
"""Rule-based analysis (no API key needed)."""
|
| 38 |
-
title = product_data.get("title", "")
|
| 39 |
-
price = product_data.get("current_price", 0) or 0
|
| 40 |
-
bsr = product_data.get("current_bsr", 0) or 0
|
| 41 |
-
rating = product_data.get("current_rating", 0) or 0
|
| 42 |
-
reviews = product_data.get("current_review_count", 0) or 0
|
| 43 |
-
monthly_sales = product_data.get("sales_estimate_monthly", 0) or 0
|
| 44 |
-
revenue = product_data.get("revenue_estimate_monthly", 0) or 0
|
| 45 |
-
score = product_data.get("opportunity_score", 0) or 0
|
| 46 |
-
category = product_data.get("category", "General") or "General"
|
| 47 |
-
|
| 48 |
-
if bsr <= 1000:
|
| 49 |
-
market_position = "Top Seller"
|
| 50 |
-
position_insight = "This product is in the top tier of Amazon sellers — extremely high demand but very competitive."
|
| 51 |
-
elif bsr <= 5000:
|
| 52 |
-
market_position = "Strong Performer"
|
| 53 |
-
position_insight = "Excellent sales velocity. High competition but proven market demand exists."
|
| 54 |
-
elif bsr <= 20000:
|
| 55 |
-
market_position = "Moderate Performer"
|
| 56 |
-
position_insight = "Solid product with consistent sales. Good entry point for new sellers."
|
| 57 |
-
elif bsr <= 100000:
|
| 58 |
-
market_position = "Niche Product"
|
| 59 |
-
position_insight = "Lower volume niche product. Less competition, but smaller market size."
|
| 60 |
-
else:
|
| 61 |
-
market_position = "Slow Mover"
|
| 62 |
-
position_insight = "Low sales velocity. May have seasonal demand or very specific audience."
|
| 63 |
-
|
| 64 |
-
if reviews >= 10000:
|
| 65 |
-
review_barrier = "Very High"
|
| 66 |
-
review_insight = f"With {reviews:,} reviews, breaking in is extremely difficult without significant investment."
|
| 67 |
-
elif reviews >= 1000:
|
| 68 |
-
review_barrier = "High"
|
| 69 |
-
review_insight = f"{reviews:,} reviews creates a significant trust barrier."
|
| 70 |
-
elif reviews >= 100:
|
| 71 |
-
review_barrier = "Medium"
|
| 72 |
-
review_insight = f"{reviews:,} reviews is manageable. Focus on getting first 50 reviews quickly."
|
| 73 |
-
else:
|
| 74 |
-
review_barrier = "Low"
|
| 75 |
-
review_insight = f"Only {reviews:,} reviews — great opportunity to compete with fresh listings."
|
| 76 |
-
|
| 77 |
-
if price >= 50:
|
| 78 |
-
price_insight = f"At ${price}, higher margins are possible. Customers expect premium quality."
|
| 79 |
-
pricing_strategy = "Premium positioning — invest in quality images and A+ content."
|
| 80 |
-
elif price >= 20:
|
| 81 |
-
price_insight = f"${price} is the sweet spot for Amazon FBA. Good margin potential."
|
| 82 |
-
pricing_strategy = "Competitive pricing — watch BSR changes when adjusting price by ±$2."
|
| 83 |
-
elif price >= 10:
|
| 84 |
-
price_insight = f"${price} is low margin territory after FBA fees."
|
| 85 |
-
pricing_strategy = "Consider bundling to improve margins."
|
| 86 |
-
else:
|
| 87 |
-
price_insight = f"At ${price}, FBA fees will consume most margin."
|
| 88 |
-
pricing_strategy = "High volume strategy required."
|
| 89 |
-
|
| 90 |
-
if rating >= 4.5:
|
| 91 |
-
rating_insight = f"{rating}★ rating is excellent. Match this quality to compete."
|
| 92 |
-
quality_bar = "Very High"
|
| 93 |
-
elif rating >= 4.0:
|
| 94 |
-
rating_insight = f"{rating}★ rating is good. Room to differentiate with better quality."
|
| 95 |
-
quality_bar = "High"
|
| 96 |
-
elif rating >= 3.5:
|
| 97 |
-
rating_insight = f"{rating}★ suggests customer dissatisfaction. Opportunity to do it better."
|
| 98 |
-
quality_bar = "Medium — Opportunity to differentiate"
|
| 99 |
-
else:
|
| 100 |
-
rating_insight = f"{rating}★ rating is poor. Strong opportunity if you solve core issues."
|
| 101 |
-
quality_bar = "Low — Big opportunity"
|
| 102 |
-
|
| 103 |
-
if score >= 70:
|
| 104 |
-
overall = "Strong Buy Signal"
|
| 105 |
-
summary = f"Promising opportunity in {category}. Strong sales velocity with manageable competition."
|
| 106 |
-
action = "Move forward with supplier sourcing. Test with a small initial order."
|
| 107 |
-
elif score >= 50:
|
| 108 |
-
overall = "Proceed with Caution"
|
| 109 |
-
summary = f"Moderate opportunity in {category}. Profitability possible but requires careful execution."
|
| 110 |
-
action = "Deep dive into top competitors before investing."
|
| 111 |
-
elif score >= 30:
|
| 112 |
-
overall = "Challenging Market"
|
| 113 |
-
summary = f"Difficult entry in {category}. High competition or low demand."
|
| 114 |
-
action = "Look for a sub-niche with less competition."
|
| 115 |
-
else:
|
| 116 |
-
overall = "Not Recommended"
|
| 117 |
-
summary = f"Unfavorable metrics in {category} for new entrants."
|
| 118 |
-
action = "Pass on this product."
|
| 119 |
-
|
| 120 |
-
recommendations: List[str] = []
|
| 121 |
-
if reviews < 50:
|
| 122 |
-
recommendations.append("Low review count — easier to rank with a new listing")
|
| 123 |
-
if rating < 4.0:
|
| 124 |
-
recommendations.append("Low rating — differentiate with better product quality")
|
| 125 |
-
if bsr > 50000:
|
| 126 |
-
recommendations.append("High BSR — validate demand before large investment")
|
| 127 |
-
if price > 30:
|
| 128 |
-
recommendations.append("Good price point — FBA margins should be healthy")
|
| 129 |
-
if monthly_sales > 500:
|
| 130 |
-
recommendations.append("High sales volume — proven market demand")
|
| 131 |
-
if score >= 60:
|
| 132 |
-
recommendations.append("Good opportunity score — worth serious consideration")
|
| 133 |
-
|
| 134 |
-
return {
|
| 135 |
-
"overall_signal": overall,
|
| 136 |
-
"summary": summary,
|
| 137 |
-
"action": action,
|
| 138 |
-
"market_position": market_position,
|
| 139 |
-
"position_insight": position_insight,
|
| 140 |
-
"review_barrier": review_barrier,
|
| 141 |
-
"review_insight": review_insight,
|
| 142 |
-
"price_insight": price_insight,
|
| 143 |
-
"pricing_strategy": pricing_strategy,
|
| 144 |
-
"rating_insight": rating_insight,
|
| 145 |
-
"quality_bar": quality_bar,
|
| 146 |
-
"recommendations": recommendations,
|
| 147 |
-
"llm_insights": [],
|
| 148 |
-
"risk_factors": [],
|
| 149 |
-
"competitive_moat": "",
|
| 150 |
-
"metrics_summary": {
|
| 151 |
-
"monthly_revenue": f"${revenue:,.0f}" if revenue else "N/A",
|
| 152 |
-
"monthly_units": f"~{monthly_sales:,}" if monthly_sales else "N/A",
|
| 153 |
-
"opportunity_score": score,
|
| 154 |
-
"market_position": market_position,
|
| 155 |
-
},
|
| 156 |
-
"used_llm": False,
|
| 157 |
-
"provider": "heuristic",
|
| 158 |
-
"engine": "rule-based",
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
def generate_ai_analysis(product_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 163 |
-
"""Generate AI analysis — LLM when API key configured, else heuristics."""
|
| 164 |
-
base = _heuristic_analysis(product_data)
|
| 165 |
-
|
| 166 |
-
if not is_llm_available():
|
| 167 |
-
return base
|
| 168 |
-
|
| 169 |
-
import json
|
| 170 |
-
|
| 171 |
-
user_prompt = (
|
| 172 |
-
f"Analyze this Amazon product for an FBA seller.\n\n"
|
| 173 |
-
f"PRODUCT DATA:\n{json.dumps(product_data, default=str)}\n\n"
|
| 174 |
-
f"Return JSON matching this schema:\n{AI_ANALYSIS_SCHEMA}"
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
-
llm_data, provider = generate_json(AI_ANALYSIS_SYSTEM, user_prompt)
|
| 178 |
-
if not llm_data:
|
| 179 |
-
return base
|
| 180 |
-
|
| 181 |
-
merged = {**base, **{k: v for k, v in llm_data.items() if v is not None}}
|
| 182 |
-
merged["used_llm"] = True
|
| 183 |
-
merged["provider"] = provider or get_active_provider() or "llm"
|
| 184 |
-
merged["engine"] = "llm"
|
| 185 |
-
merged["metrics_summary"] = base["metrics_summary"]
|
| 186 |
-
if not merged.get("recommendations"):
|
| 187 |
-
merged["recommendations"] = base["recommendations"]
|
| 188 |
-
return merged
|
|
|
|
| 1 |
+
"""AI product analysis — LLM-powered with heuristic fallback."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any, Dict, List
|
| 5 |
+
|
| 6 |
+
from app.services.llm_service import generate_json, get_active_provider, is_llm_available
|
| 7 |
+
|
| 8 |
+
AI_ANALYSIS_SYSTEM = (
|
| 9 |
+
"You are Rankora AI, an expert Amazon FBA product research analyst. "
|
| 10 |
+
"Given structured product metrics, produce a JSON analysis for sellers deciding "
|
| 11 |
+
"whether to source and launch this product. Be specific, honest, and actionable. "
|
| 12 |
+
"Use ONLY the data provided — do not invent sales figures."
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
AI_ANALYSIS_SCHEMA = """
|
| 16 |
+
{
|
| 17 |
+
"overall_signal": "emoji + short label e.g. Strong Buy Signal",
|
| 18 |
+
"summary": "2-3 sentence executive summary",
|
| 19 |
+
"action": "one clear next step",
|
| 20 |
+
"market_position": "Top Seller | Strong Performer | etc.",
|
| 21 |
+
"position_insight": "1-2 sentences",
|
| 22 |
+
"review_barrier": "Low | Medium | High | Very High",
|
| 23 |
+
"review_insight": "1-2 sentences",
|
| 24 |
+
"price_insight": "1-2 sentences",
|
| 25 |
+
"pricing_strategy": "actionable pricing advice",
|
| 26 |
+
"rating_insight": "1-2 sentences",
|
| 27 |
+
"quality_bar": "Low | Medium | High | Very High",
|
| 28 |
+
"recommendations": ["bullet 1", "bullet 2", "... up to 6"],
|
| 29 |
+
"llm_insights": ["deeper insight 1", "deeper insight 2", "... up to 4"],
|
| 30 |
+
"risk_factors": ["risk 1", "risk 2"],
|
| 31 |
+
"competitive_moat": "what protects incumbents or what gap exists"
|
| 32 |
+
}
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _heuristic_analysis(product_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 37 |
+
"""Rule-based analysis (no API key needed)."""
|
| 38 |
+
title = product_data.get("title", "")
|
| 39 |
+
price = product_data.get("current_price", 0) or 0
|
| 40 |
+
bsr = product_data.get("current_bsr", 0) or 0
|
| 41 |
+
rating = product_data.get("current_rating", 0) or 0
|
| 42 |
+
reviews = product_data.get("current_review_count", 0) or 0
|
| 43 |
+
monthly_sales = product_data.get("sales_estimate_monthly", 0) or 0
|
| 44 |
+
revenue = product_data.get("revenue_estimate_monthly", 0) or 0
|
| 45 |
+
score = product_data.get("opportunity_score", 0) or 0
|
| 46 |
+
category = product_data.get("category", "General") or "General"
|
| 47 |
+
|
| 48 |
+
if bsr <= 1000:
|
| 49 |
+
market_position = "Top Seller"
|
| 50 |
+
position_insight = "This product is in the top tier of Amazon sellers — extremely high demand but very competitive."
|
| 51 |
+
elif bsr <= 5000:
|
| 52 |
+
market_position = "Strong Performer"
|
| 53 |
+
position_insight = "Excellent sales velocity. High competition but proven market demand exists."
|
| 54 |
+
elif bsr <= 20000:
|
| 55 |
+
market_position = "Moderate Performer"
|
| 56 |
+
position_insight = "Solid product with consistent sales. Good entry point for new sellers."
|
| 57 |
+
elif bsr <= 100000:
|
| 58 |
+
market_position = "Niche Product"
|
| 59 |
+
position_insight = "Lower volume niche product. Less competition, but smaller market size."
|
| 60 |
+
else:
|
| 61 |
+
market_position = "Slow Mover"
|
| 62 |
+
position_insight = "Low sales velocity. May have seasonal demand or very specific audience."
|
| 63 |
+
|
| 64 |
+
if reviews >= 10000:
|
| 65 |
+
review_barrier = "Very High"
|
| 66 |
+
review_insight = f"With {reviews:,} reviews, breaking in is extremely difficult without significant investment."
|
| 67 |
+
elif reviews >= 1000:
|
| 68 |
+
review_barrier = "High"
|
| 69 |
+
review_insight = f"{reviews:,} reviews creates a significant trust barrier."
|
| 70 |
+
elif reviews >= 100:
|
| 71 |
+
review_barrier = "Medium"
|
| 72 |
+
review_insight = f"{reviews:,} reviews is manageable. Focus on getting first 50 reviews quickly."
|
| 73 |
+
else:
|
| 74 |
+
review_barrier = "Low"
|
| 75 |
+
review_insight = f"Only {reviews:,} reviews — great opportunity to compete with fresh listings."
|
| 76 |
+
|
| 77 |
+
if price >= 50:
|
| 78 |
+
price_insight = f"At ${price}, higher margins are possible. Customers expect premium quality."
|
| 79 |
+
pricing_strategy = "Premium positioning — invest in quality images and A+ content."
|
| 80 |
+
elif price >= 20:
|
| 81 |
+
price_insight = f"${price} is the sweet spot for Amazon FBA. Good margin potential."
|
| 82 |
+
pricing_strategy = "Competitive pricing — watch BSR changes when adjusting price by ±$2."
|
| 83 |
+
elif price >= 10:
|
| 84 |
+
price_insight = f"${price} is low margin territory after FBA fees."
|
| 85 |
+
pricing_strategy = "Consider bundling to improve margins."
|
| 86 |
+
else:
|
| 87 |
+
price_insight = f"At ${price}, FBA fees will consume most margin."
|
| 88 |
+
pricing_strategy = "High volume strategy required."
|
| 89 |
+
|
| 90 |
+
if rating >= 4.5:
|
| 91 |
+
rating_insight = f"{rating}★ rating is excellent. Match this quality to compete."
|
| 92 |
+
quality_bar = "Very High"
|
| 93 |
+
elif rating >= 4.0:
|
| 94 |
+
rating_insight = f"{rating}★ rating is good. Room to differentiate with better quality."
|
| 95 |
+
quality_bar = "High"
|
| 96 |
+
elif rating >= 3.5:
|
| 97 |
+
rating_insight = f"{rating}★ suggests customer dissatisfaction. Opportunity to do it better."
|
| 98 |
+
quality_bar = "Medium — Opportunity to differentiate"
|
| 99 |
+
else:
|
| 100 |
+
rating_insight = f"{rating}★ rating is poor. Strong opportunity if you solve core issues."
|
| 101 |
+
quality_bar = "Low — Big opportunity"
|
| 102 |
+
|
| 103 |
+
if score >= 70:
|
| 104 |
+
overall = "Strong Buy Signal"
|
| 105 |
+
summary = f"Promising opportunity in {category}. Strong sales velocity with manageable competition."
|
| 106 |
+
action = "Move forward with supplier sourcing. Test with a small initial order."
|
| 107 |
+
elif score >= 50:
|
| 108 |
+
overall = "Proceed with Caution"
|
| 109 |
+
summary = f"Moderate opportunity in {category}. Profitability possible but requires careful execution."
|
| 110 |
+
action = "Deep dive into top competitors before investing."
|
| 111 |
+
elif score >= 30:
|
| 112 |
+
overall = "Challenging Market"
|
| 113 |
+
summary = f"Difficult entry in {category}. High competition or low demand."
|
| 114 |
+
action = "Look for a sub-niche with less competition."
|
| 115 |
+
else:
|
| 116 |
+
overall = "Not Recommended"
|
| 117 |
+
summary = f"Unfavorable metrics in {category} for new entrants."
|
| 118 |
+
action = "Pass on this product."
|
| 119 |
+
|
| 120 |
+
recommendations: List[str] = []
|
| 121 |
+
if reviews < 50:
|
| 122 |
+
recommendations.append("Low review count — easier to rank with a new listing")
|
| 123 |
+
if rating < 4.0:
|
| 124 |
+
recommendations.append("Low rating — differentiate with better product quality")
|
| 125 |
+
if bsr > 50000:
|
| 126 |
+
recommendations.append("High BSR — validate demand before large investment")
|
| 127 |
+
if price > 30:
|
| 128 |
+
recommendations.append("Good price point — FBA margins should be healthy")
|
| 129 |
+
if monthly_sales > 500:
|
| 130 |
+
recommendations.append("High sales volume — proven market demand")
|
| 131 |
+
if score >= 60:
|
| 132 |
+
recommendations.append("Good opportunity score — worth serious consideration")
|
| 133 |
+
|
| 134 |
+
return {
|
| 135 |
+
"overall_signal": overall,
|
| 136 |
+
"summary": summary,
|
| 137 |
+
"action": action,
|
| 138 |
+
"market_position": market_position,
|
| 139 |
+
"position_insight": position_insight,
|
| 140 |
+
"review_barrier": review_barrier,
|
| 141 |
+
"review_insight": review_insight,
|
| 142 |
+
"price_insight": price_insight,
|
| 143 |
+
"pricing_strategy": pricing_strategy,
|
| 144 |
+
"rating_insight": rating_insight,
|
| 145 |
+
"quality_bar": quality_bar,
|
| 146 |
+
"recommendations": recommendations,
|
| 147 |
+
"llm_insights": [],
|
| 148 |
+
"risk_factors": [],
|
| 149 |
+
"competitive_moat": "",
|
| 150 |
+
"metrics_summary": {
|
| 151 |
+
"monthly_revenue": f"${revenue:,.0f}" if revenue else "N/A",
|
| 152 |
+
"monthly_units": f"~{monthly_sales:,}" if monthly_sales else "N/A",
|
| 153 |
+
"opportunity_score": score,
|
| 154 |
+
"market_position": market_position,
|
| 155 |
+
},
|
| 156 |
+
"used_llm": False,
|
| 157 |
+
"provider": "heuristic",
|
| 158 |
+
"engine": "rule-based",
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def generate_ai_analysis(product_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 163 |
+
"""Generate AI analysis — LLM when API key configured, else heuristics."""
|
| 164 |
+
base = _heuristic_analysis(product_data)
|
| 165 |
+
|
| 166 |
+
if not is_llm_available():
|
| 167 |
+
return base
|
| 168 |
+
|
| 169 |
+
import json
|
| 170 |
+
|
| 171 |
+
user_prompt = (
|
| 172 |
+
f"Analyze this Amazon product for an FBA seller.\n\n"
|
| 173 |
+
f"PRODUCT DATA:\n{json.dumps(product_data, default=str)}\n\n"
|
| 174 |
+
f"Return JSON matching this schema:\n{AI_ANALYSIS_SCHEMA}"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
llm_data, provider = generate_json(AI_ANALYSIS_SYSTEM, user_prompt)
|
| 178 |
+
if not llm_data:
|
| 179 |
+
return base
|
| 180 |
+
|
| 181 |
+
merged = {**base, **{k: v for k, v in llm_data.items() if v is not None}}
|
| 182 |
+
merged["used_llm"] = True
|
| 183 |
+
merged["provider"] = provider or get_active_provider() or "llm"
|
| 184 |
+
merged["engine"] = "llm"
|
| 185 |
+
merged["metrics_summary"] = base["metrics_summary"]
|
| 186 |
+
if not merged.get("recommendations"):
|
| 187 |
+
merged["recommendations"] = base["recommendations"]
|
| 188 |
+
return merged
|
app/services/analytics/amazon_fees.py
CHANGED
|
@@ -1,227 +1,227 @@
|
|
| 1 |
-
"""Amazon US marketplace fee formulas (Seller Central Revenue Calculator aligned).
|
| 2 |
-
|
| 3 |
-
Sources: Amazon Seller Central fee schedule (FBA fulfillment, referral, storage,
|
| 4 |
-
inbound placement). Rates reflect 2024–2025 US standard-size tiers.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
from dataclasses import dataclass
|
| 9 |
-
from typing import Dict, Literal, Optional, Tuple
|
| 10 |
-
|
| 11 |
-
Season = Literal["standard", "peak"]
|
| 12 |
-
|
| 13 |
-
# Referral fee % by category keyword (Amazon US published rates)
|
| 14 |
-
REFERRAL_RATES: Dict[str, float] = {
|
| 15 |
-
"amazon device": 0.45,
|
| 16 |
-
"apparel": 0.17,
|
| 17 |
-
"clothing": 0.17,
|
| 18 |
-
"shoe": 0.15,
|
| 19 |
-
"jewelry": 0.20,
|
| 20 |
-
"electronics": 0.08,
|
| 21 |
-
"computer": 0.08,
|
| 22 |
-
"camera": 0.08,
|
| 23 |
-
"beauty": 0.08,
|
| 24 |
-
"personal care": 0.08,
|
| 25 |
-
"health": 0.08,
|
| 26 |
-
"grocery": 0.08,
|
| 27 |
-
"baby": 0.08,
|
| 28 |
-
"book": 0.15,
|
| 29 |
-
"music": 0.15,
|
| 30 |
-
"video": 0.15,
|
| 31 |
-
"software": 0.15,
|
| 32 |
-
"video game": 0.15,
|
| 33 |
-
"game console": 0.08,
|
| 34 |
-
"toy": 0.15,
|
| 35 |
-
"home": 0.15,
|
| 36 |
-
"kitchen": 0.15,
|
| 37 |
-
"garden": 0.15,
|
| 38 |
-
"tool": 0.15,
|
| 39 |
-
"sport": 0.15,
|
| 40 |
-
"outdoor": 0.15,
|
| 41 |
-
"pet": 0.15,
|
| 42 |
-
"office": 0.15,
|
| 43 |
-
"industrial": 0.12,
|
| 44 |
-
"automotive": 0.12,
|
| 45 |
-
"default": 0.15,
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
REFERRAL_MIN = 0.30
|
| 49 |
-
|
| 50 |
-
# FBA US fulfillment — standard-size non-apparel (2024 non-peak, excl. apparel)
|
| 51 |
-
# Weight in oz → fee USD (small standard ≤16 oz, large standard ≤20 lb)
|
| 52 |
-
SMALL_STANDARD_OZ: Tuple[Tuple[float, float], ...] = (
|
| 53 |
-
(2, 3.06),
|
| 54 |
-
(4, 3.15),
|
| 55 |
-
(6, 3.24),
|
| 56 |
-
(8, 3.33),
|
| 57 |
-
(10, 3.43),
|
| 58 |
-
(12, 3.53),
|
| 59 |
-
(14, 3.60),
|
| 60 |
-
(16, 3.65),
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
LARGE_STANDARD_LBS: Tuple[Tuple[float, float], ...] = (
|
| 64 |
-
(0.25, 3.86),
|
| 65 |
-
(0.5, 4.08),
|
| 66 |
-
(0.75, 4.24),
|
| 67 |
-
(1.0, 4.75),
|
| 68 |
-
(1.25, 5.19),
|
| 69 |
-
(1.5, 5.40),
|
| 70 |
-
(1.75, 5.69),
|
| 71 |
-
(2.0, 5.87),
|
| 72 |
-
(2.25, 6.08),
|
| 73 |
-
(2.5, 6.29),
|
| 74 |
-
(2.75, 6.48),
|
| 75 |
-
(3.0, 6.85),
|
| 76 |
-
(20.0, 9.73),
|
| 77 |
-
)
|
| 78 |
-
|
| 79 |
-
# Storage $/cu ft per month (standard-size)
|
| 80 |
-
STORAGE_RATE_CUFT: Dict[Season, float] = {
|
| 81 |
-
"standard": 0.87, # Jan–Sep
|
| 82 |
-
"peak": 2.40, # Oct–Dec
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
# Default unit volume when dimensions unknown (typical small standard ~0.05 cu ft)
|
| 86 |
-
DEFAULT_CU_FT_PER_UNIT = 0.05
|
| 87 |
-
|
| 88 |
-
# Inbound placement service fee per unit (US, typical distributed inventory)
|
| 89 |
-
INBOUND_PLACEMENT_DEFAULT = 0.56
|
| 90 |
-
|
| 91 |
-
INBOUND_BY_REGION = {
|
| 92 |
-
"west": 0.56,
|
| 93 |
-
"central": 0.48,
|
| 94 |
-
"east": 0.52,
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
REMOVAL_FEE_PER_UNIT = 3.95
|
| 98 |
-
DISPOSAL_FEE_PER_UNIT = 3.95
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def calculate_removal_disposal_cost(
|
| 102 |
-
removal_units: int = 0,
|
| 103 |
-
disposal_units: int = 0,
|
| 104 |
-
monthly_units_sold: float = 1.0,
|
| 105 |
-
) -> Dict:
|
| 106 |
-
total = round(removal_units * REMOVAL_FEE_PER_UNIT + disposal_units * DISPOSAL_FEE_PER_UNIT, 2)
|
| 107 |
-
per_unit = round(total / max(monthly_units_sold, 1), 2) if total else 0.0
|
| 108 |
-
return {
|
| 109 |
-
"removal_fee_per_unit": REMOVAL_FEE_PER_UNIT,
|
| 110 |
-
"disposal_fee_per_unit": DISPOSAL_FEE_PER_UNIT,
|
| 111 |
-
"removal_units": removal_units,
|
| 112 |
-
"disposal_units": disposal_units,
|
| 113 |
-
"total_removal_disposal_fee": total,
|
| 114 |
-
"removal_disposal_cost_per_unit_sold": per_unit,
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
@dataclass
|
| 119 |
-
class ProductDimensions:
|
| 120 |
-
weight_lbs: float = 1.0
|
| 121 |
-
length_in: float = 10.0
|
| 122 |
-
width_in: float = 8.0
|
| 123 |
-
height_in: float = 2.0
|
| 124 |
-
|
| 125 |
-
@property
|
| 126 |
-
def weight_oz(self) -> float:
|
| 127 |
-
return self.weight_lbs * 16
|
| 128 |
-
|
| 129 |
-
@property
|
| 130 |
-
def cubic_feet(self) -> float:
|
| 131 |
-
return max((self.length_in * self.width_in * self.height_in) / 1728, 0.001)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def resolve_referral_rate(category: str) -> Tuple[float, str]:
|
| 135 |
-
cat = (category or "").lower()
|
| 136 |
-
for key, rate in REFERRAL_RATES.items():
|
| 137 |
-
if key != "default" and key in cat:
|
| 138 |
-
return rate, key
|
| 139 |
-
return REFERRAL_RATES["default"], "default"
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def calculate_referral_fee(price: float, category: str = "default") -> Dict:
|
| 143 |
-
rate, matched = resolve_referral_rate(category)
|
| 144 |
-
fee = round(max(price * rate, REFERRAL_MIN), 2)
|
| 145 |
-
return {
|
| 146 |
-
"referral_fee": fee,
|
| 147 |
-
"referral_rate_percent": round(rate * 100, 1),
|
| 148 |
-
"referral_category": matched,
|
| 149 |
-
"fixed_closing_fee": 0.0,
|
| 150 |
-
"variable_closing_fee": 0.0,
|
| 151 |
-
"digital_services_fee": 0.0,
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def calculate_fba_fulfillment_fee(weight_lbs: float) -> Dict:
|
| 156 |
-
"""Return FBA pick-pack-ship fee from weight tier."""
|
| 157 |
-
weight_oz = weight_lbs * 16
|
| 158 |
-
fee = 9.73
|
| 159 |
-
tier = "large_standard"
|
| 160 |
-
|
| 161 |
-
if weight_oz <= 16:
|
| 162 |
-
tier = "small_standard"
|
| 163 |
-
for max_oz, tier_fee in SMALL_STANDARD_OZ:
|
| 164 |
-
if weight_oz <= max_oz:
|
| 165 |
-
fee = tier_fee
|
| 166 |
-
break
|
| 167 |
-
else:
|
| 168 |
-
for max_lbs, tier_fee in LARGE_STANDARD_LBS:
|
| 169 |
-
if weight_lbs <= max_lbs:
|
| 170 |
-
fee = tier_fee
|
| 171 |
-
break
|
| 172 |
-
|
| 173 |
-
return {
|
| 174 |
-
"fba_fulfillment_fee": round(fee, 2),
|
| 175 |
-
"fulfillment_tier": tier,
|
| 176 |
-
"weight_lbs": round(weight_lbs, 3),
|
| 177 |
-
"weight_oz": round(weight_oz, 1),
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def calculate_storage_fee_per_unit(
|
| 182 |
-
dims: ProductDimensions,
|
| 183 |
-
season: Season = "standard",
|
| 184 |
-
avg_inventory_units: float = 1.0,
|
| 185 |
-
monthly_units_sold: float = 1.0,
|
| 186 |
-
) -> Dict:
|
| 187 |
-
monthly_per_cuft = STORAGE_RATE_CUFT[season]
|
| 188 |
-
monthly_per_unit = round(dims.cubic_feet * monthly_per_cuft, 4)
|
| 189 |
-
if monthly_units_sold <= 0:
|
| 190 |
-
per_unit_sold = 0.0
|
| 191 |
-
else:
|
| 192 |
-
per_unit_sold = round((monthly_per_unit * max(avg_inventory_units, 0)) / monthly_units_sold, 2)
|
| 193 |
-
return {
|
| 194 |
-
"season": season,
|
| 195 |
-
"cubic_feet_per_unit": round(dims.cubic_feet, 4),
|
| 196 |
-
"monthly_storage_rate_per_cuft": monthly_per_cuft,
|
| 197 |
-
"monthly_storage_cost_per_unit": monthly_per_unit,
|
| 198 |
-
"avg_inventory_units": avg_inventory_units,
|
| 199 |
-
"monthly_units_sold": monthly_units_sold,
|
| 200 |
-
"storage_cost_per_unit_sold": per_unit_sold,
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
def calculate_inbound_fees(
|
| 205 |
-
region: str = "west",
|
| 206 |
-
shipping_cost_per_shipment: float = 0.0,
|
| 207 |
-
units_per_shipment: int = 1,
|
| 208 |
-
) -> Dict:
|
| 209 |
-
region_key = (region or "west").lower()
|
| 210 |
-
placement = INBOUND_BY_REGION.get(region_key, INBOUND_PLACEMENT_DEFAULT)
|
| 211 |
-
shipping_per_unit = round(shipping_cost_per_shipment / max(units_per_shipment, 1), 2)
|
| 212 |
-
total = round(placement + shipping_per_unit, 2)
|
| 213 |
-
return {
|
| 214 |
-
"inbound_region": region_key,
|
| 215 |
-
"inbound_placement_fee_per_unit": placement,
|
| 216 |
-
"shipping_cost_per_unit": shipping_per_unit,
|
| 217 |
-
"total_inbound_cost_per_unit": total,
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def estimate_dimensions_from_weight(weight_lbs: float) -> ProductDimensions:
|
| 222 |
-
"""Estimate package size from weight when scrape has no dimensions."""
|
| 223 |
-
if weight_lbs <= 1:
|
| 224 |
-
return ProductDimensions(weight_lbs=weight_lbs, length_in=10, width_in=8, height_in=2)
|
| 225 |
-
if weight_lbs <= 3:
|
| 226 |
-
return ProductDimensions(weight_lbs=weight_lbs, length_in=14, width_in=10, height_in=4)
|
| 227 |
-
return ProductDimensions(weight_lbs=weight_lbs, length_in=18, width_in=12, height_in=6)
|
|
|
|
| 1 |
+
"""Amazon US marketplace fee formulas (Seller Central Revenue Calculator aligned).
|
| 2 |
+
|
| 3 |
+
Sources: Amazon Seller Central fee schedule (FBA fulfillment, referral, storage,
|
| 4 |
+
inbound placement). Rates reflect 2024–2025 US standard-size tiers.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Dict, Literal, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
Season = Literal["standard", "peak"]
|
| 12 |
+
|
| 13 |
+
# Referral fee % by category keyword (Amazon US published rates)
|
| 14 |
+
REFERRAL_RATES: Dict[str, float] = {
|
| 15 |
+
"amazon device": 0.45,
|
| 16 |
+
"apparel": 0.17,
|
| 17 |
+
"clothing": 0.17,
|
| 18 |
+
"shoe": 0.15,
|
| 19 |
+
"jewelry": 0.20,
|
| 20 |
+
"electronics": 0.08,
|
| 21 |
+
"computer": 0.08,
|
| 22 |
+
"camera": 0.08,
|
| 23 |
+
"beauty": 0.08,
|
| 24 |
+
"personal care": 0.08,
|
| 25 |
+
"health": 0.08,
|
| 26 |
+
"grocery": 0.08,
|
| 27 |
+
"baby": 0.08,
|
| 28 |
+
"book": 0.15,
|
| 29 |
+
"music": 0.15,
|
| 30 |
+
"video": 0.15,
|
| 31 |
+
"software": 0.15,
|
| 32 |
+
"video game": 0.15,
|
| 33 |
+
"game console": 0.08,
|
| 34 |
+
"toy": 0.15,
|
| 35 |
+
"home": 0.15,
|
| 36 |
+
"kitchen": 0.15,
|
| 37 |
+
"garden": 0.15,
|
| 38 |
+
"tool": 0.15,
|
| 39 |
+
"sport": 0.15,
|
| 40 |
+
"outdoor": 0.15,
|
| 41 |
+
"pet": 0.15,
|
| 42 |
+
"office": 0.15,
|
| 43 |
+
"industrial": 0.12,
|
| 44 |
+
"automotive": 0.12,
|
| 45 |
+
"default": 0.15,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
REFERRAL_MIN = 0.30
|
| 49 |
+
|
| 50 |
+
# FBA US fulfillment — standard-size non-apparel (2024 non-peak, excl. apparel)
|
| 51 |
+
# Weight in oz → fee USD (small standard ≤16 oz, large standard ≤20 lb)
|
| 52 |
+
SMALL_STANDARD_OZ: Tuple[Tuple[float, float], ...] = (
|
| 53 |
+
(2, 3.06),
|
| 54 |
+
(4, 3.15),
|
| 55 |
+
(6, 3.24),
|
| 56 |
+
(8, 3.33),
|
| 57 |
+
(10, 3.43),
|
| 58 |
+
(12, 3.53),
|
| 59 |
+
(14, 3.60),
|
| 60 |
+
(16, 3.65),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
LARGE_STANDARD_LBS: Tuple[Tuple[float, float], ...] = (
|
| 64 |
+
(0.25, 3.86),
|
| 65 |
+
(0.5, 4.08),
|
| 66 |
+
(0.75, 4.24),
|
| 67 |
+
(1.0, 4.75),
|
| 68 |
+
(1.25, 5.19),
|
| 69 |
+
(1.5, 5.40),
|
| 70 |
+
(1.75, 5.69),
|
| 71 |
+
(2.0, 5.87),
|
| 72 |
+
(2.25, 6.08),
|
| 73 |
+
(2.5, 6.29),
|
| 74 |
+
(2.75, 6.48),
|
| 75 |
+
(3.0, 6.85),
|
| 76 |
+
(20.0, 9.73),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Storage $/cu ft per month (standard-size)
|
| 80 |
+
STORAGE_RATE_CUFT: Dict[Season, float] = {
|
| 81 |
+
"standard": 0.87, # Jan–Sep
|
| 82 |
+
"peak": 2.40, # Oct–Dec
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# Default unit volume when dimensions unknown (typical small standard ~0.05 cu ft)
|
| 86 |
+
DEFAULT_CU_FT_PER_UNIT = 0.05
|
| 87 |
+
|
| 88 |
+
# Inbound placement service fee per unit (US, typical distributed inventory)
|
| 89 |
+
INBOUND_PLACEMENT_DEFAULT = 0.56
|
| 90 |
+
|
| 91 |
+
INBOUND_BY_REGION = {
|
| 92 |
+
"west": 0.56,
|
| 93 |
+
"central": 0.48,
|
| 94 |
+
"east": 0.52,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
REMOVAL_FEE_PER_UNIT = 3.95
|
| 98 |
+
DISPOSAL_FEE_PER_UNIT = 3.95
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def calculate_removal_disposal_cost(
|
| 102 |
+
removal_units: int = 0,
|
| 103 |
+
disposal_units: int = 0,
|
| 104 |
+
monthly_units_sold: float = 1.0,
|
| 105 |
+
) -> Dict:
|
| 106 |
+
total = round(removal_units * REMOVAL_FEE_PER_UNIT + disposal_units * DISPOSAL_FEE_PER_UNIT, 2)
|
| 107 |
+
per_unit = round(total / max(monthly_units_sold, 1), 2) if total else 0.0
|
| 108 |
+
return {
|
| 109 |
+
"removal_fee_per_unit": REMOVAL_FEE_PER_UNIT,
|
| 110 |
+
"disposal_fee_per_unit": DISPOSAL_FEE_PER_UNIT,
|
| 111 |
+
"removal_units": removal_units,
|
| 112 |
+
"disposal_units": disposal_units,
|
| 113 |
+
"total_removal_disposal_fee": total,
|
| 114 |
+
"removal_disposal_cost_per_unit_sold": per_unit,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@dataclass
|
| 119 |
+
class ProductDimensions:
|
| 120 |
+
weight_lbs: float = 1.0
|
| 121 |
+
length_in: float = 10.0
|
| 122 |
+
width_in: float = 8.0
|
| 123 |
+
height_in: float = 2.0
|
| 124 |
+
|
| 125 |
+
@property
|
| 126 |
+
def weight_oz(self) -> float:
|
| 127 |
+
return self.weight_lbs * 16
|
| 128 |
+
|
| 129 |
+
@property
|
| 130 |
+
def cubic_feet(self) -> float:
|
| 131 |
+
return max((self.length_in * self.width_in * self.height_in) / 1728, 0.001)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def resolve_referral_rate(category: str) -> Tuple[float, str]:
|
| 135 |
+
cat = (category or "").lower()
|
| 136 |
+
for key, rate in REFERRAL_RATES.items():
|
| 137 |
+
if key != "default" and key in cat:
|
| 138 |
+
return rate, key
|
| 139 |
+
return REFERRAL_RATES["default"], "default"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def calculate_referral_fee(price: float, category: str = "default") -> Dict:
|
| 143 |
+
rate, matched = resolve_referral_rate(category)
|
| 144 |
+
fee = round(max(price * rate, REFERRAL_MIN), 2)
|
| 145 |
+
return {
|
| 146 |
+
"referral_fee": fee,
|
| 147 |
+
"referral_rate_percent": round(rate * 100, 1),
|
| 148 |
+
"referral_category": matched,
|
| 149 |
+
"fixed_closing_fee": 0.0,
|
| 150 |
+
"variable_closing_fee": 0.0,
|
| 151 |
+
"digital_services_fee": 0.0,
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def calculate_fba_fulfillment_fee(weight_lbs: float) -> Dict:
|
| 156 |
+
"""Return FBA pick-pack-ship fee from weight tier."""
|
| 157 |
+
weight_oz = weight_lbs * 16
|
| 158 |
+
fee = 9.73
|
| 159 |
+
tier = "large_standard"
|
| 160 |
+
|
| 161 |
+
if weight_oz <= 16:
|
| 162 |
+
tier = "small_standard"
|
| 163 |
+
for max_oz, tier_fee in SMALL_STANDARD_OZ:
|
| 164 |
+
if weight_oz <= max_oz:
|
| 165 |
+
fee = tier_fee
|
| 166 |
+
break
|
| 167 |
+
else:
|
| 168 |
+
for max_lbs, tier_fee in LARGE_STANDARD_LBS:
|
| 169 |
+
if weight_lbs <= max_lbs:
|
| 170 |
+
fee = tier_fee
|
| 171 |
+
break
|
| 172 |
+
|
| 173 |
+
return {
|
| 174 |
+
"fba_fulfillment_fee": round(fee, 2),
|
| 175 |
+
"fulfillment_tier": tier,
|
| 176 |
+
"weight_lbs": round(weight_lbs, 3),
|
| 177 |
+
"weight_oz": round(weight_oz, 1),
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def calculate_storage_fee_per_unit(
|
| 182 |
+
dims: ProductDimensions,
|
| 183 |
+
season: Season = "standard",
|
| 184 |
+
avg_inventory_units: float = 1.0,
|
| 185 |
+
monthly_units_sold: float = 1.0,
|
| 186 |
+
) -> Dict:
|
| 187 |
+
monthly_per_cuft = STORAGE_RATE_CUFT[season]
|
| 188 |
+
monthly_per_unit = round(dims.cubic_feet * monthly_per_cuft, 4)
|
| 189 |
+
if monthly_units_sold <= 0:
|
| 190 |
+
per_unit_sold = 0.0
|
| 191 |
+
else:
|
| 192 |
+
per_unit_sold = round((monthly_per_unit * max(avg_inventory_units, 0)) / monthly_units_sold, 2)
|
| 193 |
+
return {
|
| 194 |
+
"season": season,
|
| 195 |
+
"cubic_feet_per_unit": round(dims.cubic_feet, 4),
|
| 196 |
+
"monthly_storage_rate_per_cuft": monthly_per_cuft,
|
| 197 |
+
"monthly_storage_cost_per_unit": monthly_per_unit,
|
| 198 |
+
"avg_inventory_units": avg_inventory_units,
|
| 199 |
+
"monthly_units_sold": monthly_units_sold,
|
| 200 |
+
"storage_cost_per_unit_sold": per_unit_sold,
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def calculate_inbound_fees(
|
| 205 |
+
region: str = "west",
|
| 206 |
+
shipping_cost_per_shipment: float = 0.0,
|
| 207 |
+
units_per_shipment: int = 1,
|
| 208 |
+
) -> Dict:
|
| 209 |
+
region_key = (region or "west").lower()
|
| 210 |
+
placement = INBOUND_BY_REGION.get(region_key, INBOUND_PLACEMENT_DEFAULT)
|
| 211 |
+
shipping_per_unit = round(shipping_cost_per_shipment / max(units_per_shipment, 1), 2)
|
| 212 |
+
total = round(placement + shipping_per_unit, 2)
|
| 213 |
+
return {
|
| 214 |
+
"inbound_region": region_key,
|
| 215 |
+
"inbound_placement_fee_per_unit": placement,
|
| 216 |
+
"shipping_cost_per_unit": shipping_per_unit,
|
| 217 |
+
"total_inbound_cost_per_unit": total,
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def estimate_dimensions_from_weight(weight_lbs: float) -> ProductDimensions:
|
| 222 |
+
"""Estimate package size from weight when scrape has no dimensions."""
|
| 223 |
+
if weight_lbs <= 1:
|
| 224 |
+
return ProductDimensions(weight_lbs=weight_lbs, length_in=10, width_in=8, height_in=2)
|
| 225 |
+
if weight_lbs <= 3:
|
| 226 |
+
return ProductDimensions(weight_lbs=weight_lbs, length_in=14, width_in=10, height_in=4)
|
| 227 |
+
return ProductDimensions(weight_lbs=weight_lbs, length_in=18, width_in=12, height_in=6)
|
app/services/analytics/buy_box_history.py
CHANGED
|
@@ -1,563 +1,563 @@
|
|
| 1 |
-
"""Persist and analyze Buy Box winner history over time."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
from datetime import datetime, timedelta, timezone
|
| 5 |
-
from typing import Any, Dict, List, Optional
|
| 6 |
-
|
| 7 |
-
from sqlalchemy.orm import Session
|
| 8 |
-
|
| 9 |
-
from app.models.product import BuyBoxSnapshot
|
| 10 |
-
from app.services.analytics.buy_box_rotation import estimate_buy_box_rotation
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def _normalize_winner(name: Optional[str]) -> str:
|
| 14 |
-
return (name or "Unknown").strip() or "Unknown"
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def _fba_label(is_fba: Optional[bool]) -> str:
|
| 18 |
-
if is_fba is True:
|
| 19 |
-
return "FBA"
|
| 20 |
-
if is_fba is False:
|
| 21 |
-
return "FBM"
|
| 22 |
-
return "—"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _ensure_utc(dt: datetime) -> datetime:
|
| 26 |
-
if dt.tzinfo is None:
|
| 27 |
-
return dt.replace(tzinfo=timezone.utc)
|
| 28 |
-
return dt
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def record_buy_box_snapshot(db: Session, product_id: str, data: dict) -> Optional[BuyBoxSnapshot]:
|
| 32 |
-
"""Store Buy Box state after a scrape (skip rapid duplicates)."""
|
| 33 |
-
has_buy_box = data.get("has_buy_box", True)
|
| 34 |
-
winner = _normalize_winner(data.get("buy_box_winner")) if has_buy_box else None
|
| 35 |
-
price = data.get("buy_box_price")
|
| 36 |
-
is_fba = data.get("buy_box_is_fba") if has_buy_box else None
|
| 37 |
-
is_amazon = bool(data.get("is_amazon_sold", False))
|
| 38 |
-
seller_count = data.get("seller_count")
|
| 39 |
-
|
| 40 |
-
last = (
|
| 41 |
-
db.query(BuyBoxSnapshot)
|
| 42 |
-
.filter(BuyBoxSnapshot.product_id == product_id)
|
| 43 |
-
.order_by(BuyBoxSnapshot.recorded_at.desc())
|
| 44 |
-
.first()
|
| 45 |
-
)
|
| 46 |
-
if last:
|
| 47 |
-
last_at = _ensure_utc(last.recorded_at)
|
| 48 |
-
age_min = (datetime.now(timezone.utc) - last_at).total_seconds() / 60
|
| 49 |
-
same_state = (
|
| 50 |
-
bool(last.has_buy_box) == bool(has_buy_box)
|
| 51 |
-
and _normalize_winner(last.winner) == winner
|
| 52 |
-
and float(last.price or 0) == float(price or 0)
|
| 53 |
-
and last.is_fba == is_fba
|
| 54 |
-
)
|
| 55 |
-
if same_state and age_min < 30:
|
| 56 |
-
return last
|
| 57 |
-
|
| 58 |
-
snapshot = BuyBoxSnapshot(
|
| 59 |
-
product_id=product_id,
|
| 60 |
-
winner=winner,
|
| 61 |
-
price=price,
|
| 62 |
-
is_fba=is_fba,
|
| 63 |
-
is_amazon=is_amazon,
|
| 64 |
-
seller_count=seller_count,
|
| 65 |
-
has_buy_box=has_buy_box,
|
| 66 |
-
)
|
| 67 |
-
db.add(snapshot)
|
| 68 |
-
return snapshot
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def get_buy_box_snapshots(
|
| 72 |
-
db: Session,
|
| 73 |
-
product_id: str,
|
| 74 |
-
days: int = 30,
|
| 75 |
-
limit: int = 500,
|
| 76 |
-
) -> List[BuyBoxSnapshot]:
|
| 77 |
-
cutoff = datetime.now(timezone.utc) - timedelta(days=max(days, 1))
|
| 78 |
-
return (
|
| 79 |
-
db.query(BuyBoxSnapshot)
|
| 80 |
-
.filter(
|
| 81 |
-
BuyBoxSnapshot.product_id == product_id,
|
| 82 |
-
BuyBoxSnapshot.recorded_at >= cutoff,
|
| 83 |
-
)
|
| 84 |
-
.order_by(BuyBoxSnapshot.recorded_at.asc())
|
| 85 |
-
.limit(limit)
|
| 86 |
-
.all()
|
| 87 |
-
)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def snapshots_to_dicts(snapshots: List[BuyBoxSnapshot]) -> List[dict]:
|
| 91 |
-
return [
|
| 92 |
-
{
|
| 93 |
-
"winner": s.winner,
|
| 94 |
-
"price": float(s.price) if s.price is not None else None,
|
| 95 |
-
"is_fba": s.is_fba,
|
| 96 |
-
"is_amazon": bool(s.is_amazon),
|
| 97 |
-
"seller_count": s.seller_count,
|
| 98 |
-
"has_buy_box": bool(s.has_buy_box),
|
| 99 |
-
"recorded_at": _ensure_utc(s.recorded_at).isoformat(),
|
| 100 |
-
}
|
| 101 |
-
for s in snapshots
|
| 102 |
-
]
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _last_won_from_snapshots(seller: str, snapshots: List[BuyBoxSnapshot], current_winner: Optional[str]) -> str:
|
| 106 |
-
if current_winner and _normalize_winner(current_winner) == _normalize_winner(seller):
|
| 107 |
-
return "Current"
|
| 108 |
-
for snap in reversed(snapshots):
|
| 109 |
-
if snap.has_buy_box and _normalize_winner(snap.winner) == _normalize_winner(seller):
|
| 110 |
-
days_ago = (datetime.now(timezone.utc) - _ensure_utc(snap.recorded_at)).days
|
| 111 |
-
if days_ago <= 1:
|
| 112 |
-
return "Today"
|
| 113 |
-
if days_ago <= 3:
|
| 114 |
-
return "1–3 days ago"
|
| 115 |
-
if days_ago <= 7:
|
| 116 |
-
return "4–7 days ago"
|
| 117 |
-
if days_ago <= 14:
|
| 118 |
-
return "1–2 weeks ago"
|
| 119 |
-
return f"{days_ago} days ago"
|
| 120 |
-
return "2+ weeks ago"
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def rotation_from_snapshots(
|
| 124 |
-
snapshots: List[BuyBoxSnapshot],
|
| 125 |
-
range_days: int = 30,
|
| 126 |
-
fallback_data: Optional[dict] = None,
|
| 127 |
-
) -> Dict[str, Any]:
|
| 128 |
-
"""Compute seller Buy Box % from time-weighted snapshot history."""
|
| 129 |
-
active = [s for s in snapshots if s.has_buy_box and s.winner]
|
| 130 |
-
if len(active) < 2:
|
| 131 |
-
if fallback_data:
|
| 132 |
-
est = estimate_buy_box_rotation(fallback_data)
|
| 133 |
-
est["source"] = "estimated"
|
| 134 |
-
est["snapshot_count"] = len(snapshots)
|
| 135 |
-
return est
|
| 136 |
-
return {
|
| 137 |
-
"sellers": [],
|
| 138 |
-
"range_days": range_days,
|
| 139 |
-
"eligible_fba": 0,
|
| 140 |
-
"eligible_fbm": 0,
|
| 141 |
-
"total_sellers": 0,
|
| 142 |
-
"source": "insufficient_data",
|
| 143 |
-
"snapshot_count": len(snapshots),
|
| 144 |
-
"disclaimer": "Refresh this product a few times to build Buy Box history.",
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
now = datetime.now(timezone.utc)
|
| 148 |
-
cutoff = now - timedelta(days=range_days)
|
| 149 |
-
segments: List[tuple] = []
|
| 150 |
-
|
| 151 |
-
for i, snap in enumerate(active):
|
| 152 |
-
start = _ensure_utc(snap.recorded_at)
|
| 153 |
-
if start < cutoff:
|
| 154 |
-
start = cutoff
|
| 155 |
-
if i + 1 < len(active):
|
| 156 |
-
end = _ensure_utc(active[i + 1].recorded_at)
|
| 157 |
-
else:
|
| 158 |
-
end = now
|
| 159 |
-
if end <= start:
|
| 160 |
-
continue
|
| 161 |
-
if end < cutoff:
|
| 162 |
-
continue
|
| 163 |
-
duration_hours = (end - start).total_seconds() / 3600
|
| 164 |
-
if duration_hours <= 0:
|
| 165 |
-
continue
|
| 166 |
-
segments.append(
|
| 167 |
-
(
|
| 168 |
-
_normalize_winner(snap.winner),
|
| 169 |
-
duration_hours,
|
| 170 |
-
snap.is_fba,
|
| 171 |
-
float(snap.price) if snap.price is not None else None,
|
| 172 |
-
snap.is_amazon,
|
| 173 |
-
)
|
| 174 |
-
)
|
| 175 |
-
|
| 176 |
-
if not segments:
|
| 177 |
-
if fallback_data:
|
| 178 |
-
est = estimate_buy_box_rotation(fallback_data)
|
| 179 |
-
est["source"] = "estimated"
|
| 180 |
-
est["snapshot_count"] = len(snapshots)
|
| 181 |
-
return est
|
| 182 |
-
return {"sellers": [], "range_days": range_days, "source": "insufficient_data", "snapshot_count": len(snapshots)}
|
| 183 |
-
|
| 184 |
-
totals: Dict[str, dict] = {}
|
| 185 |
-
for seller, hours, is_fba, price, is_amazon in segments:
|
| 186 |
-
entry = totals.setdefault(
|
| 187 |
-
seller,
|
| 188 |
-
{"hours": 0.0, "prices": [], "is_fba": is_fba, "is_amazon": is_amazon},
|
| 189 |
-
)
|
| 190 |
-
entry["hours"] += hours
|
| 191 |
-
if price is not None:
|
| 192 |
-
entry["prices"].append(price)
|
| 193 |
-
if is_fba is not None:
|
| 194 |
-
entry["is_fba"] = is_fba
|
| 195 |
-
|
| 196 |
-
total_hours = sum(v["hours"] for v in totals.values()) or 1.0
|
| 197 |
-
current_winner = active[-1].winner if active else None
|
| 198 |
-
|
| 199 |
-
sellers = []
|
| 200 |
-
for seller, meta in totals.items():
|
| 201 |
-
pct = round(meta["hours"] / total_hours * 100, 1)
|
| 202 |
-
avg_price = round(sum(meta["prices"]) / len(meta["prices"]), 2) if meta["prices"] else None
|
| 203 |
-
sellers.append(
|
| 204 |
-
{
|
| 205 |
-
"seller": seller,
|
| 206 |
-
"win_percent": pct,
|
| 207 |
-
"avg_price": avg_price,
|
| 208 |
-
"is_fba": meta["is_fba"],
|
| 209 |
-
"fulfillment": _fba_label(meta["is_fba"]),
|
| 210 |
-
"rating": "98%" if meta.get("is_amazon") else "95%",
|
| 211 |
-
"last_won": _last_won_from_snapshots(seller, active, current_winner),
|
| 212 |
-
"stock": "In stock",
|
| 213 |
-
}
|
| 214 |
-
)
|
| 215 |
-
|
| 216 |
-
sellers.sort(key=lambda x: x["win_percent"], reverse=True)
|
| 217 |
-
drift = round(100.0 - sum(s["win_percent"] for s in sellers), 1)
|
| 218 |
-
if sellers and drift:
|
| 219 |
-
sellers[0]["win_percent"] = round(sellers[0]["win_percent"] + drift, 1)
|
| 220 |
-
|
| 221 |
-
fba_count = sum(1 for s in sellers if s.get("is_fba") is True)
|
| 222 |
-
fbm_count = sum(1 for s in sellers if s.get("is_fba") is False)
|
| 223 |
-
latest_seller_count = active[-1].seller_count or len(sellers)
|
| 224 |
-
|
| 225 |
-
return {
|
| 226 |
-
"sellers": sellers,
|
| 227 |
-
"range_days": range_days,
|
| 228 |
-
"eligible_fba": fba_count,
|
| 229 |
-
"eligible_fbm": fbm_count,
|
| 230 |
-
"total_sellers": latest_seller_count,
|
| 231 |
-
"source": "historical",
|
| 232 |
-
"snapshot_count": len(snapshots),
|
| 233 |
-
"disclaimer": (
|
| 234 |
-
f"Buy Box share from {len(snapshots)} recorded snapshots over {range_days} days. "
|
| 235 |
-
"Track and refresh products regularly for higher accuracy."
|
| 236 |
-
),
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
def build_buy_box_timeline(snapshots: List[BuyBoxSnapshot], range_days: int = 30) -> List[dict]:
|
| 241 |
-
if not snapshots:
|
| 242 |
-
return []
|
| 243 |
-
|
| 244 |
-
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 245 |
-
by_day: Dict[str, Dict[str, float]] = {}
|
| 246 |
-
|
| 247 |
-
ordered = sorted(snapshots, key=lambda s: _ensure_utc(s.recorded_at))
|
| 248 |
-
now = datetime.now(timezone.utc)
|
| 249 |
-
|
| 250 |
-
for i, snap in enumerate(ordered):
|
| 251 |
-
if not snap.has_buy_box or not snap.winner:
|
| 252 |
-
continue
|
| 253 |
-
start = _ensure_utc(snap.recorded_at)
|
| 254 |
-
if start < cutoff:
|
| 255 |
-
start = cutoff
|
| 256 |
-
end = _ensure_utc(ordered[i + 1].recorded_at) if i + 1 < len(ordered) else now
|
| 257 |
-
if end <= start:
|
| 258 |
-
continue
|
| 259 |
-
|
| 260 |
-
cursor = start
|
| 261 |
-
while cursor < end:
|
| 262 |
-
day_key = cursor.strftime("%Y-%m-%d")
|
| 263 |
-
day_end = (cursor + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
| 264 |
-
if day_end.tzinfo is None:
|
| 265 |
-
day_end = day_end.replace(tzinfo=timezone.utc)
|
| 266 |
-
segment_end = min(end, day_end)
|
| 267 |
-
hours = max((segment_end - cursor).total_seconds() / 3600, 0)
|
| 268 |
-
if hours > 0:
|
| 269 |
-
winner = _normalize_winner(snap.winner)
|
| 270 |
-
by_day.setdefault(day_key, {})
|
| 271 |
-
by_day[day_key][winner] = by_day[day_key].get(winner, 0) + hours
|
| 272 |
-
cursor = segment_end
|
| 273 |
-
|
| 274 |
-
timeline = []
|
| 275 |
-
for day in sorted(by_day.keys()):
|
| 276 |
-
winners = by_day[day]
|
| 277 |
-
dominant = max(winners, key=winners.get)
|
| 278 |
-
total = sum(winners.values()) or 1
|
| 279 |
-
timeline.append(
|
| 280 |
-
{
|
| 281 |
-
"date": day,
|
| 282 |
-
"dominant_seller": dominant,
|
| 283 |
-
"dominant_percent": round(winners[dominant] / total * 100, 1),
|
| 284 |
-
"sellers_active": len(winners),
|
| 285 |
-
}
|
| 286 |
-
)
|
| 287 |
-
return timeline
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
def build_buy_box_price_series(snapshots: List[BuyBoxSnapshot], range_days: int = 90) -> List[dict]:
|
| 291 |
-
"""Buy Box price + winner at each snapshot for line/scatter charts."""
|
| 292 |
-
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 293 |
-
series = []
|
| 294 |
-
for s in snapshots:
|
| 295 |
-
if not s.has_buy_box:
|
| 296 |
-
continue
|
| 297 |
-
at = _ensure_utc(s.recorded_at)
|
| 298 |
-
if at < cutoff:
|
| 299 |
-
continue
|
| 300 |
-
series.append(
|
| 301 |
-
{
|
| 302 |
-
"recorded_at": at.isoformat(),
|
| 303 |
-
"date": at.strftime("%Y-%m-%d"),
|
| 304 |
-
"buy_box_price": float(s.price) if s.price is not None else None,
|
| 305 |
-
"winner": s.winner,
|
| 306 |
-
"seller_count": s.seller_count,
|
| 307 |
-
"is_fba": s.is_fba,
|
| 308 |
-
}
|
| 309 |
-
)
|
| 310 |
-
return series
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
def build_seller_count_series(snapshots: List[BuyBoxSnapshot], range_days: int = 90) -> List[dict]:
|
| 314 |
-
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 315 |
-
series = []
|
| 316 |
-
for s in snapshots:
|
| 317 |
-
at = _ensure_utc(s.recorded_at)
|
| 318 |
-
if at < cutoff:
|
| 319 |
-
continue
|
| 320 |
-
series.append(
|
| 321 |
-
{
|
| 322 |
-
"recorded_at": at.isoformat(),
|
| 323 |
-
"date": at.strftime("%Y-%m-%d %H:%M"),
|
| 324 |
-
"seller_count": s.seller_count or 1,
|
| 325 |
-
"has_buy_box": bool(s.has_buy_box),
|
| 326 |
-
}
|
| 327 |
-
)
|
| 328 |
-
return series
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
def build_seller_rotation_series(snapshots: List[BuyBoxSnapshot], range_days: int = 30, top_n: int = 5) -> List[dict]:
|
| 332 |
-
"""
|
| 333 |
-
Weekly stacked Buy Box share % per seller (for area/bar history chart).
|
| 334 |
-
"""
|
| 335 |
-
active = [s for s in snapshots if s.has_buy_box and s.winner]
|
| 336 |
-
if len(active) < 2:
|
| 337 |
-
return []
|
| 338 |
-
|
| 339 |
-
now = datetime.now(timezone.utc)
|
| 340 |
-
cutoff = now - timedelta(days=range_days)
|
| 341 |
-
weekly: Dict[str, Dict[str, float]] = {}
|
| 342 |
-
|
| 343 |
-
for i, snap in enumerate(active):
|
| 344 |
-
start = _ensure_utc(snap.recorded_at)
|
| 345 |
-
if start < cutoff:
|
| 346 |
-
start = cutoff
|
| 347 |
-
end = _ensure_utc(active[i + 1].recorded_at) if i + 1 < len(active) else now
|
| 348 |
-
if end <= start:
|
| 349 |
-
continue
|
| 350 |
-
week_key = start.strftime("%Y-W%W")
|
| 351 |
-
hours = (end - start).total_seconds() / 3600
|
| 352 |
-
seller = _normalize_winner(snap.winner)
|
| 353 |
-
weekly.setdefault(week_key, {})
|
| 354 |
-
weekly[week_key][seller] = weekly[week_key].get(seller, 0) + hours
|
| 355 |
-
|
| 356 |
-
all_sellers: Dict[str, float] = {}
|
| 357 |
-
for w in weekly.values():
|
| 358 |
-
for seller, h in w.items():
|
| 359 |
-
all_sellers[seller] = all_sellers.get(seller, 0) + h
|
| 360 |
-
top_sellers = [s for s, _ in sorted(all_sellers.items(), key=lambda x: x[1], reverse=True)[:top_n]]
|
| 361 |
-
|
| 362 |
-
result = []
|
| 363 |
-
for week in sorted(weekly.keys()):
|
| 364 |
-
winners = weekly[week]
|
| 365 |
-
total = sum(winners.values()) or 1
|
| 366 |
-
row: Dict[str, Any] = {"week": week}
|
| 367 |
-
for seller in top_sellers:
|
| 368 |
-
row[seller] = round(winners.get(seller, 0) / total * 100, 1)
|
| 369 |
-
row["other"] = round(
|
| 370 |
-
sum(v for s, v in winners.items() if s not in top_sellers) / total * 100, 1
|
| 371 |
-
)
|
| 372 |
-
result.append(row)
|
| 373 |
-
return result
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
def build_market_history_summary(
|
| 377 |
-
snapshots: List[BuyBoxSnapshot],
|
| 378 |
-
price_history: List[dict],
|
| 379 |
-
range_days: int = 30,
|
| 380 |
-
) -> dict:
|
| 381 |
-
"""Human-readable summary of how much history exists."""
|
| 382 |
-
bb_prices = [float(s.price) for s in snapshots if s.price is not None]
|
| 383 |
-
hist_prices = [float(r["price"]) for r in price_history if r.get("price") is not None]
|
| 384 |
-
all_prices = bb_prices or hist_prices
|
| 385 |
-
return {
|
| 386 |
-
"range_days": range_days,
|
| 387 |
-
"buy_box_snapshots": len(snapshots),
|
| 388 |
-
"price_points": len(price_history),
|
| 389 |
-
"price_current": round(all_prices[-1], 2) if all_prices else None,
|
| 390 |
-
"price_avg": round(sum(all_prices) / len(all_prices), 2) if all_prices else None,
|
| 391 |
-
"price_min": round(min(all_prices), 2) if all_prices else None,
|
| 392 |
-
"price_max": round(max(all_prices), 2) if all_prices else None,
|
| 393 |
-
"first_snapshot": _ensure_utc(snapshots[0].recorded_at).isoformat() if snapshots else None,
|
| 394 |
-
"last_snapshot": _ensure_utc(snapshots[-1].recorded_at).isoformat() if snapshots else None,
|
| 395 |
-
"first_price_record": price_history[0]["recorded_at"] if price_history else None,
|
| 396 |
-
"last_price_record": price_history[-1]["recorded_at"] if price_history else None,
|
| 397 |
-
"buy_box_price_min": round(min(bb_prices), 2) if bb_prices else None,
|
| 398 |
-
"buy_box_price_max": round(max(bb_prices), 2) if bb_prices else None,
|
| 399 |
-
"ready_for_rotation_chart": len(snapshots) >= 2,
|
| 400 |
-
"ready_for_weekly_rotation": len(snapshots) >= 4,
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
def build_monthly_aggregates(price_history: List[dict], field: str = "price") -> List[dict]:
|
| 405 |
-
"""Average metric by calendar month (YYYY-MM)."""
|
| 406 |
-
buckets: Dict[str, List[float]] = {}
|
| 407 |
-
for row in price_history:
|
| 408 |
-
val = row.get(field)
|
| 409 |
-
ts = row.get("recorded_at") or ""
|
| 410 |
-
if val is None or not ts:
|
| 411 |
-
continue
|
| 412 |
-
month = ts[:7]
|
| 413 |
-
buckets.setdefault(month, []).append(float(val))
|
| 414 |
-
return [
|
| 415 |
-
{"period": m, "label": m, "avg": round(sum(v) / len(v), 2), "count": len(v)}
|
| 416 |
-
for m, v in sorted(buckets.items())
|
| 417 |
-
]
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
def build_yearly_aggregates(price_history: List[dict], field: str = "price") -> List[dict]:
|
| 421 |
-
buckets: Dict[str, List[float]] = {}
|
| 422 |
-
for row in price_history:
|
| 423 |
-
val = row.get(field)
|
| 424 |
-
ts = row.get("recorded_at") or ""
|
| 425 |
-
if val is None or not ts:
|
| 426 |
-
continue
|
| 427 |
-
year = ts[:4]
|
| 428 |
-
buckets.setdefault(year, []).append(float(val))
|
| 429 |
-
return [
|
| 430 |
-
{"period": y, "label": y, "avg": round(sum(v) / len(v), 2), "count": len(v)}
|
| 431 |
-
for y, v in sorted(buckets.items())
|
| 432 |
-
]
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def build_keepa_style_series(
|
| 436 |
-
price_history: List[dict],
|
| 437 |
-
snapshots: List[BuyBoxSnapshot],
|
| 438 |
-
range_days: int = 365,
|
| 439 |
-
) -> List[dict]:
|
| 440 |
-
"""Combined Keepa-style row: price, BSR, seller count per date."""
|
| 441 |
-
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 442 |
-
bb_by_date: Dict[str, dict] = {}
|
| 443 |
-
for s in snapshots:
|
| 444 |
-
at = _ensure_utc(s.recorded_at)
|
| 445 |
-
if at < cutoff:
|
| 446 |
-
continue
|
| 447 |
-
d = at.strftime("%Y-%m-%d")
|
| 448 |
-
bb_by_date[d] = {
|
| 449 |
-
"buy_box_price": float(s.price) if s.price else None,
|
| 450 |
-
"seller_count": s.seller_count,
|
| 451 |
-
"winner": s.winner,
|
| 452 |
-
}
|
| 453 |
-
rows = []
|
| 454 |
-
for row in price_history:
|
| 455 |
-
ts = row.get("recorded_at") or ""
|
| 456 |
-
if not ts:
|
| 457 |
-
continue
|
| 458 |
-
try:
|
| 459 |
-
if datetime.fromisoformat(ts.replace("Z", "+00:00")) < cutoff:
|
| 460 |
-
continue
|
| 461 |
-
except Exception:
|
| 462 |
-
pass
|
| 463 |
-
d = ts[:10]
|
| 464 |
-
bb = bb_by_date.get(d, {})
|
| 465 |
-
rows.append(
|
| 466 |
-
{
|
| 467 |
-
"date": d,
|
| 468 |
-
"price": row.get("price"),
|
| 469 |
-
"bsr": row.get("bsr"),
|
| 470 |
-
"review_count": row.get("review_count"),
|
| 471 |
-
"buy_box_price": bb.get("buy_box_price"),
|
| 472 |
-
"seller_count": bb.get("seller_count"),
|
| 473 |
-
"buy_box_winner": bb.get("winner"),
|
| 474 |
-
}
|
| 475 |
-
)
|
| 476 |
-
return rows
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
def build_historical_charts_payload(
|
| 480 |
-
snapshots: List[BuyBoxSnapshot],
|
| 481 |
-
price_history: List[dict],
|
| 482 |
-
category: str = "default",
|
| 483 |
-
range_days: int = 90,
|
| 484 |
-
product_cost: float = 0.0,
|
| 485 |
-
misc_cost: float = 0.0,
|
| 486 |
-
weight_lbs: float = 1.0,
|
| 487 |
-
) -> dict:
|
| 488 |
-
from app.services.amazon.sales_estimator import estimate_monthly_sales
|
| 489 |
-
from app.services.analytics.profit_calculator import build_profit_history_series
|
| 490 |
-
|
| 491 |
-
revenue_series = []
|
| 492 |
-
for row in price_history:
|
| 493 |
-
bsr = row.get("bsr") or 0
|
| 494 |
-
price = row.get("price")
|
| 495 |
-
if not price:
|
| 496 |
-
continue
|
| 497 |
-
sales = estimate_monthly_sales(bsr, category)
|
| 498 |
-
units = sales.get("monthly_units") or 0
|
| 499 |
-
revenue_series.append(
|
| 500 |
-
{
|
| 501 |
-
"recorded_at": row.get("recorded_at"),
|
| 502 |
-
"date": (row.get("recorded_at") or "")[:10],
|
| 503 |
-
"price": price,
|
| 504 |
-
"bsr": bsr,
|
| 505 |
-
"review_count": row.get("review_count"),
|
| 506 |
-
"monthly_units_est": units,
|
| 507 |
-
"monthly_revenue_est": round(units * float(price), 2) if units else None,
|
| 508 |
-
}
|
| 509 |
-
)
|
| 510 |
-
|
| 511 |
-
rotation_series = build_seller_rotation_series(snapshots, range_days=min(range_days, 90))
|
| 512 |
-
top_sellers = []
|
| 513 |
-
if rotation_series:
|
| 514 |
-
keys = set()
|
| 515 |
-
for row in rotation_series:
|
| 516 |
-
keys.update(k for k in row.keys() if k not in ("week", "other"))
|
| 517 |
-
top_sellers = sorted(keys)
|
| 518 |
-
|
| 519 |
-
return {
|
| 520 |
-
"range_days": range_days,
|
| 521 |
-
"summary": build_market_history_summary(snapshots, price_history, range_days),
|
| 522 |
-
"buy_box_price_series": build_buy_box_price_series(snapshots, range_days),
|
| 523 |
-
"seller_count_series": build_seller_count_series(snapshots, range_days),
|
| 524 |
-
"buy_box_timeline": build_buy_box_timeline(snapshots, min(range_days, 365)),
|
| 525 |
-
"seller_rotation_weekly": rotation_series,
|
| 526 |
-
"seller_rotation_labels": top_sellers + (["other"] if rotation_series else []),
|
| 527 |
-
"revenue_estimate_series": revenue_series,
|
| 528 |
-
"profit_comparison_series": build_profit_history_series(
|
| 529 |
-
price_history, category, weight_lbs, product_cost, misc_cost
|
| 530 |
-
),
|
| 531 |
-
"keepa_style_series": build_keepa_style_series(price_history, snapshots, range_days),
|
| 532 |
-
"price_monthly": build_monthly_aggregates(price_history, "price"),
|
| 533 |
-
"price_yearly": build_yearly_aggregates(price_history, "price"),
|
| 534 |
-
"bsr_monthly": build_monthly_aggregates(price_history, "bsr"),
|
| 535 |
-
"bsr_yearly": build_yearly_aggregates(price_history, "bsr"),
|
| 536 |
-
"range_options": [30, 90, 180, 365],
|
| 537 |
-
"how_it_works": [
|
| 538 |
-
"Track this ASIN — Rankora refreshes every 6 hours (or on manual Refresh).",
|
| 539 |
-
"Each refresh saves price/BSR to price_history and Buy Box winner to buy_box_snapshots.",
|
| 540 |
-
"Buy Box % (Keepa-style) = hours each seller held the box between snapshots.",
|
| 541 |
-
"Monthly/yearly charts aggregate your stored refresh data — not dummy estimates.",
|
| 542 |
-
"Profit history recalculates FBA vs FBM at each past price using Amazon US fee formulas.",
|
| 543 |
-
],
|
| 544 |
-
}
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
def build_buy_box_history_payload(
|
| 548 |
-
db: Session,
|
| 549 |
-
product_id: str,
|
| 550 |
-
range_days: int = 30,
|
| 551 |
-
fallback_data: Optional[dict] = None,
|
| 552 |
-
) -> dict:
|
| 553 |
-
snapshots = get_buy_box_snapshots(db, product_id, days=range_days)
|
| 554 |
-
rotation = rotation_from_snapshots(snapshots, range_days=range_days, fallback_data=fallback_data)
|
| 555 |
-
price_hist = fallback_data.get("price_history") if fallback_data else []
|
| 556 |
-
return {
|
| 557 |
-
"range_days": range_days,
|
| 558 |
-
"snapshot_count": len(snapshots),
|
| 559 |
-
"snapshots": snapshots_to_dicts(snapshots),
|
| 560 |
-
"timeline": build_buy_box_timeline(snapshots, range_days=range_days),
|
| 561 |
-
"rotation": rotation,
|
| 562 |
-
"charts": build_historical_charts_payload(snapshots, price_hist or [], category=(fallback_data or {}).get("category", "default"), range_days=range_days),
|
| 563 |
-
}
|
|
|
|
| 1 |
+
"""Persist and analyze Buy Box winner history over time."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.models.product import BuyBoxSnapshot
|
| 10 |
+
from app.services.analytics.buy_box_rotation import estimate_buy_box_rotation
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _normalize_winner(name: Optional[str]) -> str:
|
| 14 |
+
return (name or "Unknown").strip() or "Unknown"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _fba_label(is_fba: Optional[bool]) -> str:
|
| 18 |
+
if is_fba is True:
|
| 19 |
+
return "FBA"
|
| 20 |
+
if is_fba is False:
|
| 21 |
+
return "FBM"
|
| 22 |
+
return "—"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _ensure_utc(dt: datetime) -> datetime:
|
| 26 |
+
if dt.tzinfo is None:
|
| 27 |
+
return dt.replace(tzinfo=timezone.utc)
|
| 28 |
+
return dt
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def record_buy_box_snapshot(db: Session, product_id: str, data: dict) -> Optional[BuyBoxSnapshot]:
|
| 32 |
+
"""Store Buy Box state after a scrape (skip rapid duplicates)."""
|
| 33 |
+
has_buy_box = data.get("has_buy_box", True)
|
| 34 |
+
winner = _normalize_winner(data.get("buy_box_winner")) if has_buy_box else None
|
| 35 |
+
price = data.get("buy_box_price")
|
| 36 |
+
is_fba = data.get("buy_box_is_fba") if has_buy_box else None
|
| 37 |
+
is_amazon = bool(data.get("is_amazon_sold", False))
|
| 38 |
+
seller_count = data.get("seller_count")
|
| 39 |
+
|
| 40 |
+
last = (
|
| 41 |
+
db.query(BuyBoxSnapshot)
|
| 42 |
+
.filter(BuyBoxSnapshot.product_id == product_id)
|
| 43 |
+
.order_by(BuyBoxSnapshot.recorded_at.desc())
|
| 44 |
+
.first()
|
| 45 |
+
)
|
| 46 |
+
if last:
|
| 47 |
+
last_at = _ensure_utc(last.recorded_at)
|
| 48 |
+
age_min = (datetime.now(timezone.utc) - last_at).total_seconds() / 60
|
| 49 |
+
same_state = (
|
| 50 |
+
bool(last.has_buy_box) == bool(has_buy_box)
|
| 51 |
+
and _normalize_winner(last.winner) == winner
|
| 52 |
+
and float(last.price or 0) == float(price or 0)
|
| 53 |
+
and last.is_fba == is_fba
|
| 54 |
+
)
|
| 55 |
+
if same_state and age_min < 30:
|
| 56 |
+
return last
|
| 57 |
+
|
| 58 |
+
snapshot = BuyBoxSnapshot(
|
| 59 |
+
product_id=product_id,
|
| 60 |
+
winner=winner,
|
| 61 |
+
price=price,
|
| 62 |
+
is_fba=is_fba,
|
| 63 |
+
is_amazon=is_amazon,
|
| 64 |
+
seller_count=seller_count,
|
| 65 |
+
has_buy_box=has_buy_box,
|
| 66 |
+
)
|
| 67 |
+
db.add(snapshot)
|
| 68 |
+
return snapshot
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_buy_box_snapshots(
|
| 72 |
+
db: Session,
|
| 73 |
+
product_id: str,
|
| 74 |
+
days: int = 30,
|
| 75 |
+
limit: int = 500,
|
| 76 |
+
) -> List[BuyBoxSnapshot]:
|
| 77 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=max(days, 1))
|
| 78 |
+
return (
|
| 79 |
+
db.query(BuyBoxSnapshot)
|
| 80 |
+
.filter(
|
| 81 |
+
BuyBoxSnapshot.product_id == product_id,
|
| 82 |
+
BuyBoxSnapshot.recorded_at >= cutoff,
|
| 83 |
+
)
|
| 84 |
+
.order_by(BuyBoxSnapshot.recorded_at.asc())
|
| 85 |
+
.limit(limit)
|
| 86 |
+
.all()
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def snapshots_to_dicts(snapshots: List[BuyBoxSnapshot]) -> List[dict]:
|
| 91 |
+
return [
|
| 92 |
+
{
|
| 93 |
+
"winner": s.winner,
|
| 94 |
+
"price": float(s.price) if s.price is not None else None,
|
| 95 |
+
"is_fba": s.is_fba,
|
| 96 |
+
"is_amazon": bool(s.is_amazon),
|
| 97 |
+
"seller_count": s.seller_count,
|
| 98 |
+
"has_buy_box": bool(s.has_buy_box),
|
| 99 |
+
"recorded_at": _ensure_utc(s.recorded_at).isoformat(),
|
| 100 |
+
}
|
| 101 |
+
for s in snapshots
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _last_won_from_snapshots(seller: str, snapshots: List[BuyBoxSnapshot], current_winner: Optional[str]) -> str:
|
| 106 |
+
if current_winner and _normalize_winner(current_winner) == _normalize_winner(seller):
|
| 107 |
+
return "Current"
|
| 108 |
+
for snap in reversed(snapshots):
|
| 109 |
+
if snap.has_buy_box and _normalize_winner(snap.winner) == _normalize_winner(seller):
|
| 110 |
+
days_ago = (datetime.now(timezone.utc) - _ensure_utc(snap.recorded_at)).days
|
| 111 |
+
if days_ago <= 1:
|
| 112 |
+
return "Today"
|
| 113 |
+
if days_ago <= 3:
|
| 114 |
+
return "1–3 days ago"
|
| 115 |
+
if days_ago <= 7:
|
| 116 |
+
return "4–7 days ago"
|
| 117 |
+
if days_ago <= 14:
|
| 118 |
+
return "1–2 weeks ago"
|
| 119 |
+
return f"{days_ago} days ago"
|
| 120 |
+
return "2+ weeks ago"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def rotation_from_snapshots(
|
| 124 |
+
snapshots: List[BuyBoxSnapshot],
|
| 125 |
+
range_days: int = 30,
|
| 126 |
+
fallback_data: Optional[dict] = None,
|
| 127 |
+
) -> Dict[str, Any]:
|
| 128 |
+
"""Compute seller Buy Box % from time-weighted snapshot history."""
|
| 129 |
+
active = [s for s in snapshots if s.has_buy_box and s.winner]
|
| 130 |
+
if len(active) < 2:
|
| 131 |
+
if fallback_data:
|
| 132 |
+
est = estimate_buy_box_rotation(fallback_data)
|
| 133 |
+
est["source"] = "estimated"
|
| 134 |
+
est["snapshot_count"] = len(snapshots)
|
| 135 |
+
return est
|
| 136 |
+
return {
|
| 137 |
+
"sellers": [],
|
| 138 |
+
"range_days": range_days,
|
| 139 |
+
"eligible_fba": 0,
|
| 140 |
+
"eligible_fbm": 0,
|
| 141 |
+
"total_sellers": 0,
|
| 142 |
+
"source": "insufficient_data",
|
| 143 |
+
"snapshot_count": len(snapshots),
|
| 144 |
+
"disclaimer": "Refresh this product a few times to build Buy Box history.",
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
now = datetime.now(timezone.utc)
|
| 148 |
+
cutoff = now - timedelta(days=range_days)
|
| 149 |
+
segments: List[tuple] = []
|
| 150 |
+
|
| 151 |
+
for i, snap in enumerate(active):
|
| 152 |
+
start = _ensure_utc(snap.recorded_at)
|
| 153 |
+
if start < cutoff:
|
| 154 |
+
start = cutoff
|
| 155 |
+
if i + 1 < len(active):
|
| 156 |
+
end = _ensure_utc(active[i + 1].recorded_at)
|
| 157 |
+
else:
|
| 158 |
+
end = now
|
| 159 |
+
if end <= start:
|
| 160 |
+
continue
|
| 161 |
+
if end < cutoff:
|
| 162 |
+
continue
|
| 163 |
+
duration_hours = (end - start).total_seconds() / 3600
|
| 164 |
+
if duration_hours <= 0:
|
| 165 |
+
continue
|
| 166 |
+
segments.append(
|
| 167 |
+
(
|
| 168 |
+
_normalize_winner(snap.winner),
|
| 169 |
+
duration_hours,
|
| 170 |
+
snap.is_fba,
|
| 171 |
+
float(snap.price) if snap.price is not None else None,
|
| 172 |
+
snap.is_amazon,
|
| 173 |
+
)
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
if not segments:
|
| 177 |
+
if fallback_data:
|
| 178 |
+
est = estimate_buy_box_rotation(fallback_data)
|
| 179 |
+
est["source"] = "estimated"
|
| 180 |
+
est["snapshot_count"] = len(snapshots)
|
| 181 |
+
return est
|
| 182 |
+
return {"sellers": [], "range_days": range_days, "source": "insufficient_data", "snapshot_count": len(snapshots)}
|
| 183 |
+
|
| 184 |
+
totals: Dict[str, dict] = {}
|
| 185 |
+
for seller, hours, is_fba, price, is_amazon in segments:
|
| 186 |
+
entry = totals.setdefault(
|
| 187 |
+
seller,
|
| 188 |
+
{"hours": 0.0, "prices": [], "is_fba": is_fba, "is_amazon": is_amazon},
|
| 189 |
+
)
|
| 190 |
+
entry["hours"] += hours
|
| 191 |
+
if price is not None:
|
| 192 |
+
entry["prices"].append(price)
|
| 193 |
+
if is_fba is not None:
|
| 194 |
+
entry["is_fba"] = is_fba
|
| 195 |
+
|
| 196 |
+
total_hours = sum(v["hours"] for v in totals.values()) or 1.0
|
| 197 |
+
current_winner = active[-1].winner if active else None
|
| 198 |
+
|
| 199 |
+
sellers = []
|
| 200 |
+
for seller, meta in totals.items():
|
| 201 |
+
pct = round(meta["hours"] / total_hours * 100, 1)
|
| 202 |
+
avg_price = round(sum(meta["prices"]) / len(meta["prices"]), 2) if meta["prices"] else None
|
| 203 |
+
sellers.append(
|
| 204 |
+
{
|
| 205 |
+
"seller": seller,
|
| 206 |
+
"win_percent": pct,
|
| 207 |
+
"avg_price": avg_price,
|
| 208 |
+
"is_fba": meta["is_fba"],
|
| 209 |
+
"fulfillment": _fba_label(meta["is_fba"]),
|
| 210 |
+
"rating": "98%" if meta.get("is_amazon") else "95%",
|
| 211 |
+
"last_won": _last_won_from_snapshots(seller, active, current_winner),
|
| 212 |
+
"stock": "In stock",
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
sellers.sort(key=lambda x: x["win_percent"], reverse=True)
|
| 217 |
+
drift = round(100.0 - sum(s["win_percent"] for s in sellers), 1)
|
| 218 |
+
if sellers and drift:
|
| 219 |
+
sellers[0]["win_percent"] = round(sellers[0]["win_percent"] + drift, 1)
|
| 220 |
+
|
| 221 |
+
fba_count = sum(1 for s in sellers if s.get("is_fba") is True)
|
| 222 |
+
fbm_count = sum(1 for s in sellers if s.get("is_fba") is False)
|
| 223 |
+
latest_seller_count = active[-1].seller_count or len(sellers)
|
| 224 |
+
|
| 225 |
+
return {
|
| 226 |
+
"sellers": sellers,
|
| 227 |
+
"range_days": range_days,
|
| 228 |
+
"eligible_fba": fba_count,
|
| 229 |
+
"eligible_fbm": fbm_count,
|
| 230 |
+
"total_sellers": latest_seller_count,
|
| 231 |
+
"source": "historical",
|
| 232 |
+
"snapshot_count": len(snapshots),
|
| 233 |
+
"disclaimer": (
|
| 234 |
+
f"Buy Box share from {len(snapshots)} recorded snapshots over {range_days} days. "
|
| 235 |
+
"Track and refresh products regularly for higher accuracy."
|
| 236 |
+
),
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def build_buy_box_timeline(snapshots: List[BuyBoxSnapshot], range_days: int = 30) -> List[dict]:
|
| 241 |
+
if not snapshots:
|
| 242 |
+
return []
|
| 243 |
+
|
| 244 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 245 |
+
by_day: Dict[str, Dict[str, float]] = {}
|
| 246 |
+
|
| 247 |
+
ordered = sorted(snapshots, key=lambda s: _ensure_utc(s.recorded_at))
|
| 248 |
+
now = datetime.now(timezone.utc)
|
| 249 |
+
|
| 250 |
+
for i, snap in enumerate(ordered):
|
| 251 |
+
if not snap.has_buy_box or not snap.winner:
|
| 252 |
+
continue
|
| 253 |
+
start = _ensure_utc(snap.recorded_at)
|
| 254 |
+
if start < cutoff:
|
| 255 |
+
start = cutoff
|
| 256 |
+
end = _ensure_utc(ordered[i + 1].recorded_at) if i + 1 < len(ordered) else now
|
| 257 |
+
if end <= start:
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
cursor = start
|
| 261 |
+
while cursor < end:
|
| 262 |
+
day_key = cursor.strftime("%Y-%m-%d")
|
| 263 |
+
day_end = (cursor + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
| 264 |
+
if day_end.tzinfo is None:
|
| 265 |
+
day_end = day_end.replace(tzinfo=timezone.utc)
|
| 266 |
+
segment_end = min(end, day_end)
|
| 267 |
+
hours = max((segment_end - cursor).total_seconds() / 3600, 0)
|
| 268 |
+
if hours > 0:
|
| 269 |
+
winner = _normalize_winner(snap.winner)
|
| 270 |
+
by_day.setdefault(day_key, {})
|
| 271 |
+
by_day[day_key][winner] = by_day[day_key].get(winner, 0) + hours
|
| 272 |
+
cursor = segment_end
|
| 273 |
+
|
| 274 |
+
timeline = []
|
| 275 |
+
for day in sorted(by_day.keys()):
|
| 276 |
+
winners = by_day[day]
|
| 277 |
+
dominant = max(winners, key=winners.get)
|
| 278 |
+
total = sum(winners.values()) or 1
|
| 279 |
+
timeline.append(
|
| 280 |
+
{
|
| 281 |
+
"date": day,
|
| 282 |
+
"dominant_seller": dominant,
|
| 283 |
+
"dominant_percent": round(winners[dominant] / total * 100, 1),
|
| 284 |
+
"sellers_active": len(winners),
|
| 285 |
+
}
|
| 286 |
+
)
|
| 287 |
+
return timeline
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def build_buy_box_price_series(snapshots: List[BuyBoxSnapshot], range_days: int = 90) -> List[dict]:
|
| 291 |
+
"""Buy Box price + winner at each snapshot for line/scatter charts."""
|
| 292 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 293 |
+
series = []
|
| 294 |
+
for s in snapshots:
|
| 295 |
+
if not s.has_buy_box:
|
| 296 |
+
continue
|
| 297 |
+
at = _ensure_utc(s.recorded_at)
|
| 298 |
+
if at < cutoff:
|
| 299 |
+
continue
|
| 300 |
+
series.append(
|
| 301 |
+
{
|
| 302 |
+
"recorded_at": at.isoformat(),
|
| 303 |
+
"date": at.strftime("%Y-%m-%d"),
|
| 304 |
+
"buy_box_price": float(s.price) if s.price is not None else None,
|
| 305 |
+
"winner": s.winner,
|
| 306 |
+
"seller_count": s.seller_count,
|
| 307 |
+
"is_fba": s.is_fba,
|
| 308 |
+
}
|
| 309 |
+
)
|
| 310 |
+
return series
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def build_seller_count_series(snapshots: List[BuyBoxSnapshot], range_days: int = 90) -> List[dict]:
|
| 314 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 315 |
+
series = []
|
| 316 |
+
for s in snapshots:
|
| 317 |
+
at = _ensure_utc(s.recorded_at)
|
| 318 |
+
if at < cutoff:
|
| 319 |
+
continue
|
| 320 |
+
series.append(
|
| 321 |
+
{
|
| 322 |
+
"recorded_at": at.isoformat(),
|
| 323 |
+
"date": at.strftime("%Y-%m-%d %H:%M"),
|
| 324 |
+
"seller_count": s.seller_count or 1,
|
| 325 |
+
"has_buy_box": bool(s.has_buy_box),
|
| 326 |
+
}
|
| 327 |
+
)
|
| 328 |
+
return series
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def build_seller_rotation_series(snapshots: List[BuyBoxSnapshot], range_days: int = 30, top_n: int = 5) -> List[dict]:
|
| 332 |
+
"""
|
| 333 |
+
Weekly stacked Buy Box share % per seller (for area/bar history chart).
|
| 334 |
+
"""
|
| 335 |
+
active = [s for s in snapshots if s.has_buy_box and s.winner]
|
| 336 |
+
if len(active) < 2:
|
| 337 |
+
return []
|
| 338 |
+
|
| 339 |
+
now = datetime.now(timezone.utc)
|
| 340 |
+
cutoff = now - timedelta(days=range_days)
|
| 341 |
+
weekly: Dict[str, Dict[str, float]] = {}
|
| 342 |
+
|
| 343 |
+
for i, snap in enumerate(active):
|
| 344 |
+
start = _ensure_utc(snap.recorded_at)
|
| 345 |
+
if start < cutoff:
|
| 346 |
+
start = cutoff
|
| 347 |
+
end = _ensure_utc(active[i + 1].recorded_at) if i + 1 < len(active) else now
|
| 348 |
+
if end <= start:
|
| 349 |
+
continue
|
| 350 |
+
week_key = start.strftime("%Y-W%W")
|
| 351 |
+
hours = (end - start).total_seconds() / 3600
|
| 352 |
+
seller = _normalize_winner(snap.winner)
|
| 353 |
+
weekly.setdefault(week_key, {})
|
| 354 |
+
weekly[week_key][seller] = weekly[week_key].get(seller, 0) + hours
|
| 355 |
+
|
| 356 |
+
all_sellers: Dict[str, float] = {}
|
| 357 |
+
for w in weekly.values():
|
| 358 |
+
for seller, h in w.items():
|
| 359 |
+
all_sellers[seller] = all_sellers.get(seller, 0) + h
|
| 360 |
+
top_sellers = [s for s, _ in sorted(all_sellers.items(), key=lambda x: x[1], reverse=True)[:top_n]]
|
| 361 |
+
|
| 362 |
+
result = []
|
| 363 |
+
for week in sorted(weekly.keys()):
|
| 364 |
+
winners = weekly[week]
|
| 365 |
+
total = sum(winners.values()) or 1
|
| 366 |
+
row: Dict[str, Any] = {"week": week}
|
| 367 |
+
for seller in top_sellers:
|
| 368 |
+
row[seller] = round(winners.get(seller, 0) / total * 100, 1)
|
| 369 |
+
row["other"] = round(
|
| 370 |
+
sum(v for s, v in winners.items() if s not in top_sellers) / total * 100, 1
|
| 371 |
+
)
|
| 372 |
+
result.append(row)
|
| 373 |
+
return result
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def build_market_history_summary(
|
| 377 |
+
snapshots: List[BuyBoxSnapshot],
|
| 378 |
+
price_history: List[dict],
|
| 379 |
+
range_days: int = 30,
|
| 380 |
+
) -> dict:
|
| 381 |
+
"""Human-readable summary of how much history exists."""
|
| 382 |
+
bb_prices = [float(s.price) for s in snapshots if s.price is not None]
|
| 383 |
+
hist_prices = [float(r["price"]) for r in price_history if r.get("price") is not None]
|
| 384 |
+
all_prices = bb_prices or hist_prices
|
| 385 |
+
return {
|
| 386 |
+
"range_days": range_days,
|
| 387 |
+
"buy_box_snapshots": len(snapshots),
|
| 388 |
+
"price_points": len(price_history),
|
| 389 |
+
"price_current": round(all_prices[-1], 2) if all_prices else None,
|
| 390 |
+
"price_avg": round(sum(all_prices) / len(all_prices), 2) if all_prices else None,
|
| 391 |
+
"price_min": round(min(all_prices), 2) if all_prices else None,
|
| 392 |
+
"price_max": round(max(all_prices), 2) if all_prices else None,
|
| 393 |
+
"first_snapshot": _ensure_utc(snapshots[0].recorded_at).isoformat() if snapshots else None,
|
| 394 |
+
"last_snapshot": _ensure_utc(snapshots[-1].recorded_at).isoformat() if snapshots else None,
|
| 395 |
+
"first_price_record": price_history[0]["recorded_at"] if price_history else None,
|
| 396 |
+
"last_price_record": price_history[-1]["recorded_at"] if price_history else None,
|
| 397 |
+
"buy_box_price_min": round(min(bb_prices), 2) if bb_prices else None,
|
| 398 |
+
"buy_box_price_max": round(max(bb_prices), 2) if bb_prices else None,
|
| 399 |
+
"ready_for_rotation_chart": len(snapshots) >= 2,
|
| 400 |
+
"ready_for_weekly_rotation": len(snapshots) >= 4,
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def build_monthly_aggregates(price_history: List[dict], field: str = "price") -> List[dict]:
|
| 405 |
+
"""Average metric by calendar month (YYYY-MM)."""
|
| 406 |
+
buckets: Dict[str, List[float]] = {}
|
| 407 |
+
for row in price_history:
|
| 408 |
+
val = row.get(field)
|
| 409 |
+
ts = row.get("recorded_at") or ""
|
| 410 |
+
if val is None or not ts:
|
| 411 |
+
continue
|
| 412 |
+
month = ts[:7]
|
| 413 |
+
buckets.setdefault(month, []).append(float(val))
|
| 414 |
+
return [
|
| 415 |
+
{"period": m, "label": m, "avg": round(sum(v) / len(v), 2), "count": len(v)}
|
| 416 |
+
for m, v in sorted(buckets.items())
|
| 417 |
+
]
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def build_yearly_aggregates(price_history: List[dict], field: str = "price") -> List[dict]:
|
| 421 |
+
buckets: Dict[str, List[float]] = {}
|
| 422 |
+
for row in price_history:
|
| 423 |
+
val = row.get(field)
|
| 424 |
+
ts = row.get("recorded_at") or ""
|
| 425 |
+
if val is None or not ts:
|
| 426 |
+
continue
|
| 427 |
+
year = ts[:4]
|
| 428 |
+
buckets.setdefault(year, []).append(float(val))
|
| 429 |
+
return [
|
| 430 |
+
{"period": y, "label": y, "avg": round(sum(v) / len(v), 2), "count": len(v)}
|
| 431 |
+
for y, v in sorted(buckets.items())
|
| 432 |
+
]
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def build_keepa_style_series(
|
| 436 |
+
price_history: List[dict],
|
| 437 |
+
snapshots: List[BuyBoxSnapshot],
|
| 438 |
+
range_days: int = 365,
|
| 439 |
+
) -> List[dict]:
|
| 440 |
+
"""Combined Keepa-style row: price, BSR, seller count per date."""
|
| 441 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=range_days)
|
| 442 |
+
bb_by_date: Dict[str, dict] = {}
|
| 443 |
+
for s in snapshots:
|
| 444 |
+
at = _ensure_utc(s.recorded_at)
|
| 445 |
+
if at < cutoff:
|
| 446 |
+
continue
|
| 447 |
+
d = at.strftime("%Y-%m-%d")
|
| 448 |
+
bb_by_date[d] = {
|
| 449 |
+
"buy_box_price": float(s.price) if s.price else None,
|
| 450 |
+
"seller_count": s.seller_count,
|
| 451 |
+
"winner": s.winner,
|
| 452 |
+
}
|
| 453 |
+
rows = []
|
| 454 |
+
for row in price_history:
|
| 455 |
+
ts = row.get("recorded_at") or ""
|
| 456 |
+
if not ts:
|
| 457 |
+
continue
|
| 458 |
+
try:
|
| 459 |
+
if datetime.fromisoformat(ts.replace("Z", "+00:00")) < cutoff:
|
| 460 |
+
continue
|
| 461 |
+
except Exception:
|
| 462 |
+
pass
|
| 463 |
+
d = ts[:10]
|
| 464 |
+
bb = bb_by_date.get(d, {})
|
| 465 |
+
rows.append(
|
| 466 |
+
{
|
| 467 |
+
"date": d,
|
| 468 |
+
"price": row.get("price"),
|
| 469 |
+
"bsr": row.get("bsr"),
|
| 470 |
+
"review_count": row.get("review_count"),
|
| 471 |
+
"buy_box_price": bb.get("buy_box_price"),
|
| 472 |
+
"seller_count": bb.get("seller_count"),
|
| 473 |
+
"buy_box_winner": bb.get("winner"),
|
| 474 |
+
}
|
| 475 |
+
)
|
| 476 |
+
return rows
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def build_historical_charts_payload(
|
| 480 |
+
snapshots: List[BuyBoxSnapshot],
|
| 481 |
+
price_history: List[dict],
|
| 482 |
+
category: str = "default",
|
| 483 |
+
range_days: int = 90,
|
| 484 |
+
product_cost: float = 0.0,
|
| 485 |
+
misc_cost: float = 0.0,
|
| 486 |
+
weight_lbs: float = 1.0,
|
| 487 |
+
) -> dict:
|
| 488 |
+
from app.services.amazon.sales_estimator import estimate_monthly_sales
|
| 489 |
+
from app.services.analytics.profit_calculator import build_profit_history_series
|
| 490 |
+
|
| 491 |
+
revenue_series = []
|
| 492 |
+
for row in price_history:
|
| 493 |
+
bsr = row.get("bsr") or 0
|
| 494 |
+
price = row.get("price")
|
| 495 |
+
if not price:
|
| 496 |
+
continue
|
| 497 |
+
sales = estimate_monthly_sales(bsr, category)
|
| 498 |
+
units = sales.get("monthly_units") or 0
|
| 499 |
+
revenue_series.append(
|
| 500 |
+
{
|
| 501 |
+
"recorded_at": row.get("recorded_at"),
|
| 502 |
+
"date": (row.get("recorded_at") or "")[:10],
|
| 503 |
+
"price": price,
|
| 504 |
+
"bsr": bsr,
|
| 505 |
+
"review_count": row.get("review_count"),
|
| 506 |
+
"monthly_units_est": units,
|
| 507 |
+
"monthly_revenue_est": round(units * float(price), 2) if units else None,
|
| 508 |
+
}
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
rotation_series = build_seller_rotation_series(snapshots, range_days=min(range_days, 90))
|
| 512 |
+
top_sellers = []
|
| 513 |
+
if rotation_series:
|
| 514 |
+
keys = set()
|
| 515 |
+
for row in rotation_series:
|
| 516 |
+
keys.update(k for k in row.keys() if k not in ("week", "other"))
|
| 517 |
+
top_sellers = sorted(keys)
|
| 518 |
+
|
| 519 |
+
return {
|
| 520 |
+
"range_days": range_days,
|
| 521 |
+
"summary": build_market_history_summary(snapshots, price_history, range_days),
|
| 522 |
+
"buy_box_price_series": build_buy_box_price_series(snapshots, range_days),
|
| 523 |
+
"seller_count_series": build_seller_count_series(snapshots, range_days),
|
| 524 |
+
"buy_box_timeline": build_buy_box_timeline(snapshots, min(range_days, 365)),
|
| 525 |
+
"seller_rotation_weekly": rotation_series,
|
| 526 |
+
"seller_rotation_labels": top_sellers + (["other"] if rotation_series else []),
|
| 527 |
+
"revenue_estimate_series": revenue_series,
|
| 528 |
+
"profit_comparison_series": build_profit_history_series(
|
| 529 |
+
price_history, category, weight_lbs, product_cost, misc_cost
|
| 530 |
+
),
|
| 531 |
+
"keepa_style_series": build_keepa_style_series(price_history, snapshots, range_days),
|
| 532 |
+
"price_monthly": build_monthly_aggregates(price_history, "price"),
|
| 533 |
+
"price_yearly": build_yearly_aggregates(price_history, "price"),
|
| 534 |
+
"bsr_monthly": build_monthly_aggregates(price_history, "bsr"),
|
| 535 |
+
"bsr_yearly": build_yearly_aggregates(price_history, "bsr"),
|
| 536 |
+
"range_options": [30, 90, 180, 365],
|
| 537 |
+
"how_it_works": [
|
| 538 |
+
"Track this ASIN — Rankora refreshes every 6 hours (or on manual Refresh).",
|
| 539 |
+
"Each refresh saves price/BSR to price_history and Buy Box winner to buy_box_snapshots.",
|
| 540 |
+
"Buy Box % (Keepa-style) = hours each seller held the box between snapshots.",
|
| 541 |
+
"Monthly/yearly charts aggregate your stored refresh data — not dummy estimates.",
|
| 542 |
+
"Profit history recalculates FBA vs FBM at each past price using Amazon US fee formulas.",
|
| 543 |
+
],
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def build_buy_box_history_payload(
|
| 548 |
+
db: Session,
|
| 549 |
+
product_id: str,
|
| 550 |
+
range_days: int = 30,
|
| 551 |
+
fallback_data: Optional[dict] = None,
|
| 552 |
+
) -> dict:
|
| 553 |
+
snapshots = get_buy_box_snapshots(db, product_id, days=range_days)
|
| 554 |
+
rotation = rotation_from_snapshots(snapshots, range_days=range_days, fallback_data=fallback_data)
|
| 555 |
+
price_hist = fallback_data.get("price_history") if fallback_data else []
|
| 556 |
+
return {
|
| 557 |
+
"range_days": range_days,
|
| 558 |
+
"snapshot_count": len(snapshots),
|
| 559 |
+
"snapshots": snapshots_to_dicts(snapshots),
|
| 560 |
+
"timeline": build_buy_box_timeline(snapshots, range_days=range_days),
|
| 561 |
+
"rotation": rotation,
|
| 562 |
+
"charts": build_historical_charts_payload(snapshots, price_hist or [], category=(fallback_data or {}).get("category", "default"), range_days=range_days),
|
| 563 |
+
}
|
app/services/analytics/buy_box_rotation.py
CHANGED
|
@@ -1,153 +1,153 @@
|
|
| 1 |
-
"""Buy Box rotation from real scraped offers and historical snapshots only."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
from typing import Any, Dict, List, Optional
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def _fba_label(is_fba: Optional[bool]) -> str:
|
| 8 |
-
if is_fba is True:
|
| 9 |
-
return "FBA"
|
| 10 |
-
if is_fba is False:
|
| 11 |
-
return "FBM"
|
| 12 |
-
return "—"
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def estimate_buy_box_rotation(data: dict) -> Dict[str, Any]:
|
| 16 |
-
"""
|
| 17 |
-
Build seller Buy Box share table from live offer data only.
|
| 18 |
-
No synthetic sellers — if Amazon does not expose offers, returns empty with guidance.
|
| 19 |
-
"""
|
| 20 |
-
seller_count = max(int(data.get("seller_count") or 1), 1)
|
| 21 |
-
buy_box_price = float(data.get("buy_box_price") or data.get("price") or 0) or 0.0
|
| 22 |
-
winner = (data.get("buy_box_winner") or "Buy Box Winner").strip()
|
| 23 |
-
is_fba_winner = data.get("buy_box_is_fba")
|
| 24 |
-
is_amazon = bool(data.get("is_amazon_sold"))
|
| 25 |
-
other_sellers: List[dict] = list(data.get("other_sellers") or [])
|
| 26 |
-
|
| 27 |
-
offers: List[dict] = []
|
| 28 |
-
|
| 29 |
-
if data.get("has_buy_box", True):
|
| 30 |
-
offers.append(
|
| 31 |
-
{
|
| 32 |
-
"name": winner,
|
| 33 |
-
"is_fba": is_fba_winner if is_fba_winner is not None else True,
|
| 34 |
-
"price": buy_box_price,
|
| 35 |
-
"rating": "98%" if is_amazon else "95%",
|
| 36 |
-
"is_current_winner": True,
|
| 37 |
-
}
|
| 38 |
-
)
|
| 39 |
-
|
| 40 |
-
for s in other_sellers:
|
| 41 |
-
name = (s.get("name") or "").strip()
|
| 42 |
-
if not name or name.lower() == winner.lower():
|
| 43 |
-
continue
|
| 44 |
-
offers.append(
|
| 45 |
-
{
|
| 46 |
-
"name": name,
|
| 47 |
-
"is_fba": s.get("is_fba"),
|
| 48 |
-
"price": float(s.get("price") or buy_box_price) if (s.get("price") or buy_box_price) else None,
|
| 49 |
-
"rating": s.get("rating") or "90%",
|
| 50 |
-
"is_current_winner": False,
|
| 51 |
-
}
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
if len(offers) <= 1 and seller_count > 1:
|
| 55 |
-
return {
|
| 56 |
-
"sellers": [],
|
| 57 |
-
"range_days": 30,
|
| 58 |
-
"eligible_fba": data.get("fba_seller_count") or 0,
|
| 59 |
-
"eligible_fbm": data.get("fbm_seller_count") or 0,
|
| 60 |
-
"total_sellers": seller_count,
|
| 61 |
-
"source": "insufficient_offers",
|
| 62 |
-
"disclaimer": (
|
| 63 |
-
f"Amazon reports {seller_count} sellers but offer details were not available. "
|
| 64 |
-
"Refresh the product or track it to build Buy Box history from snapshots."
|
| 65 |
-
),
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
if not offers:
|
| 69 |
-
return {
|
| 70 |
-
"sellers": [],
|
| 71 |
-
"range_days": 30,
|
| 72 |
-
"eligible_fba": 0,
|
| 73 |
-
"eligible_fbm": 0,
|
| 74 |
-
"total_sellers": seller_count,
|
| 75 |
-
"source": "no_data",
|
| 76 |
-
"disclaimer": "No Buy Box offer data available for this listing.",
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
# Weight by competitive strength: current winner, FBA, price proximity
|
| 80 |
-
weights: List[float] = []
|
| 81 |
-
for i, offer in enumerate(offers):
|
| 82 |
-
w = 1.0
|
| 83 |
-
if offer.get("is_current_winner"):
|
| 84 |
-
w *= 4.0
|
| 85 |
-
if offer.get("is_fba") is True:
|
| 86 |
-
w *= 2.2
|
| 87 |
-
elif offer.get("is_fba") is False:
|
| 88 |
-
w *= 0.65
|
| 89 |
-
if is_amazon and i == 0:
|
| 90 |
-
w *= 2.5
|
| 91 |
-
if buy_box_price and offer.get("price"):
|
| 92 |
-
gap = abs(offer["price"] - buy_box_price) / buy_box_price
|
| 93 |
-
if gap <= 0.02:
|
| 94 |
-
w *= 1.35
|
| 95 |
-
elif gap <= 0.08:
|
| 96 |
-
w *= 1.1
|
| 97 |
-
else:
|
| 98 |
-
w *= max(0.45, 1 - gap)
|
| 99 |
-
weights.append(w)
|
| 100 |
-
|
| 101 |
-
total_w = sum(weights) or 1.0
|
| 102 |
-
raw_pcts = [round(w / total_w * 100, 1) for w in weights]
|
| 103 |
-
drift = round(100.0 - sum(raw_pcts), 1)
|
| 104 |
-
if raw_pcts:
|
| 105 |
-
raw_pcts[0] = round(raw_pcts[0] + drift, 1)
|
| 106 |
-
|
| 107 |
-
rotation = []
|
| 108 |
-
for offer, pct in zip(offers, raw_pcts):
|
| 109 |
-
rotation.append(
|
| 110 |
-
{
|
| 111 |
-
"seller": offer["name"],
|
| 112 |
-
"win_percent": pct,
|
| 113 |
-
"avg_price": offer.get("price"),
|
| 114 |
-
"is_fba": offer.get("is_fba"),
|
| 115 |
-
"fulfillment": _fba_label(offer.get("is_fba")),
|
| 116 |
-
"rating": offer.get("rating"),
|
| 117 |
-
"last_won": "Current" if offer.get("is_current_winner") else _last_won_label(pct),
|
| 118 |
-
"stock": "In stock",
|
| 119 |
-
}
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
rotation.sort(key=lambda x: x["win_percent"], reverse=True)
|
| 123 |
-
|
| 124 |
-
fba_count = data.get("fba_seller_count")
|
| 125 |
-
fbm_count = data.get("fbm_seller_count")
|
| 126 |
-
if fba_count is None:
|
| 127 |
-
fba_count = sum(1 for o in offers if o.get("is_fba") is True)
|
| 128 |
-
if fbm_count is None:
|
| 129 |
-
fbm_count = sum(1 for o in offers if o.get("is_fba") is False)
|
| 130 |
-
|
| 131 |
-
offers_source = data.get("offers_source", "live")
|
| 132 |
-
return {
|
| 133 |
-
"sellers": rotation,
|
| 134 |
-
"range_days": 30,
|
| 135 |
-
"eligible_fba": fba_count,
|
| 136 |
-
"eligible_fbm": fbm_count,
|
| 137 |
-
"total_sellers": max(seller_count, len(offers)),
|
| 138 |
-
"source": "live_offers" if len(offers) > 1 else "estimated",
|
| 139 |
-
"disclaimer": (
|
| 140 |
-
f"Buy Box share from {len(offers)} live Amazon offers ({offers_source}). "
|
| 141 |
-
"Track and refresh for historical rotation accuracy."
|
| 142 |
-
),
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _last_won_label(win_percent: float) -> str:
|
| 147 |
-
if win_percent >= 25:
|
| 148 |
-
return "1–3 days ago"
|
| 149 |
-
if win_percent >= 10:
|
| 150 |
-
return "4–7 days ago"
|
| 151 |
-
if win_percent >= 5:
|
| 152 |
-
return "1–2 weeks ago"
|
| 153 |
-
return "2+ weeks ago"
|
|
|
|
| 1 |
+
"""Buy Box rotation from real scraped offers and historical snapshots only."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _fba_label(is_fba: Optional[bool]) -> str:
|
| 8 |
+
if is_fba is True:
|
| 9 |
+
return "FBA"
|
| 10 |
+
if is_fba is False:
|
| 11 |
+
return "FBM"
|
| 12 |
+
return "—"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def estimate_buy_box_rotation(data: dict) -> Dict[str, Any]:
|
| 16 |
+
"""
|
| 17 |
+
Build seller Buy Box share table from live offer data only.
|
| 18 |
+
No synthetic sellers — if Amazon does not expose offers, returns empty with guidance.
|
| 19 |
+
"""
|
| 20 |
+
seller_count = max(int(data.get("seller_count") or 1), 1)
|
| 21 |
+
buy_box_price = float(data.get("buy_box_price") or data.get("price") or 0) or 0.0
|
| 22 |
+
winner = (data.get("buy_box_winner") or "Buy Box Winner").strip()
|
| 23 |
+
is_fba_winner = data.get("buy_box_is_fba")
|
| 24 |
+
is_amazon = bool(data.get("is_amazon_sold"))
|
| 25 |
+
other_sellers: List[dict] = list(data.get("other_sellers") or [])
|
| 26 |
+
|
| 27 |
+
offers: List[dict] = []
|
| 28 |
+
|
| 29 |
+
if data.get("has_buy_box", True):
|
| 30 |
+
offers.append(
|
| 31 |
+
{
|
| 32 |
+
"name": winner,
|
| 33 |
+
"is_fba": is_fba_winner if is_fba_winner is not None else True,
|
| 34 |
+
"price": buy_box_price,
|
| 35 |
+
"rating": "98%" if is_amazon else "95%",
|
| 36 |
+
"is_current_winner": True,
|
| 37 |
+
}
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
for s in other_sellers:
|
| 41 |
+
name = (s.get("name") or "").strip()
|
| 42 |
+
if not name or name.lower() == winner.lower():
|
| 43 |
+
continue
|
| 44 |
+
offers.append(
|
| 45 |
+
{
|
| 46 |
+
"name": name,
|
| 47 |
+
"is_fba": s.get("is_fba"),
|
| 48 |
+
"price": float(s.get("price") or buy_box_price) if (s.get("price") or buy_box_price) else None,
|
| 49 |
+
"rating": s.get("rating") or "90%",
|
| 50 |
+
"is_current_winner": False,
|
| 51 |
+
}
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
if len(offers) <= 1 and seller_count > 1:
|
| 55 |
+
return {
|
| 56 |
+
"sellers": [],
|
| 57 |
+
"range_days": 30,
|
| 58 |
+
"eligible_fba": data.get("fba_seller_count") or 0,
|
| 59 |
+
"eligible_fbm": data.get("fbm_seller_count") or 0,
|
| 60 |
+
"total_sellers": seller_count,
|
| 61 |
+
"source": "insufficient_offers",
|
| 62 |
+
"disclaimer": (
|
| 63 |
+
f"Amazon reports {seller_count} sellers but offer details were not available. "
|
| 64 |
+
"Refresh the product or track it to build Buy Box history from snapshots."
|
| 65 |
+
),
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if not offers:
|
| 69 |
+
return {
|
| 70 |
+
"sellers": [],
|
| 71 |
+
"range_days": 30,
|
| 72 |
+
"eligible_fba": 0,
|
| 73 |
+
"eligible_fbm": 0,
|
| 74 |
+
"total_sellers": seller_count,
|
| 75 |
+
"source": "no_data",
|
| 76 |
+
"disclaimer": "No Buy Box offer data available for this listing.",
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
# Weight by competitive strength: current winner, FBA, price proximity
|
| 80 |
+
weights: List[float] = []
|
| 81 |
+
for i, offer in enumerate(offers):
|
| 82 |
+
w = 1.0
|
| 83 |
+
if offer.get("is_current_winner"):
|
| 84 |
+
w *= 4.0
|
| 85 |
+
if offer.get("is_fba") is True:
|
| 86 |
+
w *= 2.2
|
| 87 |
+
elif offer.get("is_fba") is False:
|
| 88 |
+
w *= 0.65
|
| 89 |
+
if is_amazon and i == 0:
|
| 90 |
+
w *= 2.5
|
| 91 |
+
if buy_box_price and offer.get("price"):
|
| 92 |
+
gap = abs(offer["price"] - buy_box_price) / buy_box_price
|
| 93 |
+
if gap <= 0.02:
|
| 94 |
+
w *= 1.35
|
| 95 |
+
elif gap <= 0.08:
|
| 96 |
+
w *= 1.1
|
| 97 |
+
else:
|
| 98 |
+
w *= max(0.45, 1 - gap)
|
| 99 |
+
weights.append(w)
|
| 100 |
+
|
| 101 |
+
total_w = sum(weights) or 1.0
|
| 102 |
+
raw_pcts = [round(w / total_w * 100, 1) for w in weights]
|
| 103 |
+
drift = round(100.0 - sum(raw_pcts), 1)
|
| 104 |
+
if raw_pcts:
|
| 105 |
+
raw_pcts[0] = round(raw_pcts[0] + drift, 1)
|
| 106 |
+
|
| 107 |
+
rotation = []
|
| 108 |
+
for offer, pct in zip(offers, raw_pcts):
|
| 109 |
+
rotation.append(
|
| 110 |
+
{
|
| 111 |
+
"seller": offer["name"],
|
| 112 |
+
"win_percent": pct,
|
| 113 |
+
"avg_price": offer.get("price"),
|
| 114 |
+
"is_fba": offer.get("is_fba"),
|
| 115 |
+
"fulfillment": _fba_label(offer.get("is_fba")),
|
| 116 |
+
"rating": offer.get("rating"),
|
| 117 |
+
"last_won": "Current" if offer.get("is_current_winner") else _last_won_label(pct),
|
| 118 |
+
"stock": "In stock",
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
rotation.sort(key=lambda x: x["win_percent"], reverse=True)
|
| 123 |
+
|
| 124 |
+
fba_count = data.get("fba_seller_count")
|
| 125 |
+
fbm_count = data.get("fbm_seller_count")
|
| 126 |
+
if fba_count is None:
|
| 127 |
+
fba_count = sum(1 for o in offers if o.get("is_fba") is True)
|
| 128 |
+
if fbm_count is None:
|
| 129 |
+
fbm_count = sum(1 for o in offers if o.get("is_fba") is False)
|
| 130 |
+
|
| 131 |
+
offers_source = data.get("offers_source", "live")
|
| 132 |
+
return {
|
| 133 |
+
"sellers": rotation,
|
| 134 |
+
"range_days": 30,
|
| 135 |
+
"eligible_fba": fba_count,
|
| 136 |
+
"eligible_fbm": fbm_count,
|
| 137 |
+
"total_sellers": max(seller_count, len(offers)),
|
| 138 |
+
"source": "live_offers" if len(offers) > 1 else "estimated",
|
| 139 |
+
"disclaimer": (
|
| 140 |
+
f"Buy Box share from {len(offers)} live Amazon offers ({offers_source}). "
|
| 141 |
+
"Track and refresh for historical rotation accuracy."
|
| 142 |
+
),
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _last_won_label(win_percent: float) -> str:
|
| 147 |
+
if win_percent >= 25:
|
| 148 |
+
return "1–3 days ago"
|
| 149 |
+
if win_percent >= 10:
|
| 150 |
+
return "4–7 days ago"
|
| 151 |
+
if win_percent >= 5:
|
| 152 |
+
return "1–2 weeks ago"
|
| 153 |
+
return "2+ weeks ago"
|
app/services/analytics/profit_calculator.py
CHANGED
|
@@ -1,360 +1,360 @@
|
|
| 1 |
-
"""Amazon Seller Central–style revenue / profit calculator (FBA vs FBM)."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
from typing import Dict, Literal, Optional
|
| 5 |
-
|
| 6 |
-
from app.services.analytics.amazon_fees import (
|
| 7 |
-
ProductDimensions,
|
| 8 |
-
calculate_fba_fulfillment_fee,
|
| 9 |
-
calculate_inbound_fees,
|
| 10 |
-
calculate_referral_fee,
|
| 11 |
-
calculate_removal_disposal_cost,
|
| 12 |
-
calculate_storage_fee_per_unit,
|
| 13 |
-
estimate_dimensions_from_weight,
|
| 14 |
-
)
|
| 15 |
-
|
| 16 |
-
FulfillmentMode = Literal["fba", "fbm"]
|
| 17 |
-
Season = Literal["standard", "peak"]
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def _build_fee_section(
|
| 21 |
-
selling_price: float,
|
| 22 |
-
category: str,
|
| 23 |
-
dims: ProductDimensions,
|
| 24 |
-
mode: FulfillmentMode,
|
| 25 |
-
*,
|
| 26 |
-
season: Season = "standard",
|
| 27 |
-
inbound_region: str = "west",
|
| 28 |
-
shipping_cost_per_shipment: float = 0.0,
|
| 29 |
-
units_per_shipment: int = 1,
|
| 30 |
-
fbm_fulfillment_cost: float = 0.0,
|
| 31 |
-
avg_inventory_units: float = 1.0,
|
| 32 |
-
monthly_units_sold: float = 1.0,
|
| 33 |
-
product_cost: float = 0.0,
|
| 34 |
-
misc_cost: float = 0.0,
|
| 35 |
-
shipping_charge: float = 0.0,
|
| 36 |
-
removal_units: int = 0,
|
| 37 |
-
disposal_units: int = 0,
|
| 38 |
-
) -> Dict:
|
| 39 |
-
referral = calculate_referral_fee(selling_price, category)
|
| 40 |
-
amazon_fees_total = round(
|
| 41 |
-
referral["referral_fee"]
|
| 42 |
-
+ referral["fixed_closing_fee"]
|
| 43 |
-
+ referral["variable_closing_fee"]
|
| 44 |
-
+ referral["digital_services_fee"],
|
| 45 |
-
2,
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
-
fulfillment_block: Dict = {"fulfillment_cost": 0.0, "fba_fulfillment_fee": 0.0}
|
| 49 |
-
inbound_block: Dict = {"total_inbound_cost_per_unit": 0.0}
|
| 50 |
-
storage = calculate_storage_fee_per_unit(dims, season, avg_inventory_units, monthly_units_sold)
|
| 51 |
-
|
| 52 |
-
if mode == "fba":
|
| 53 |
-
fulfillment_block = calculate_fba_fulfillment_fee(dims.weight_lbs)
|
| 54 |
-
inbound_block = calculate_inbound_fees(inbound_region, shipping_cost_per_shipment, units_per_shipment)
|
| 55 |
-
fulfillment_total = fulfillment_block["fba_fulfillment_fee"]
|
| 56 |
-
else:
|
| 57 |
-
fulfillment_total = round(fbm_fulfillment_cost, 2)
|
| 58 |
-
fulfillment_block = {"fulfillment_cost": fulfillment_total, "fba_fulfillment_fee": 0.0}
|
| 59 |
-
|
| 60 |
-
storage_per_unit = storage["storage_cost_per_unit_sold"]
|
| 61 |
-
inbound_per_unit = inbound_block["total_inbound_cost_per_unit"] if mode == "fba" else 0.0
|
| 62 |
-
removal_block = calculate_removal_disposal_cost(removal_units, disposal_units, monthly_units_sold)
|
| 63 |
-
removal_per_unit = removal_block["removal_disposal_cost_per_unit_sold"] if mode == "fba" else 0.0
|
| 64 |
-
|
| 65 |
-
effective_revenue = selling_price + (shipping_charge if mode == "fbm" else 0.0)
|
| 66 |
-
total_costs = round(
|
| 67 |
-
amazon_fees_total
|
| 68 |
-
+ fulfillment_total
|
| 69 |
-
+ inbound_per_unit
|
| 70 |
-
+ storage_per_unit
|
| 71 |
-
+ removal_per_unit
|
| 72 |
-
+ product_cost
|
| 73 |
-
+ misc_cost,
|
| 74 |
-
2,
|
| 75 |
-
)
|
| 76 |
-
net_proceeds = round(effective_revenue - total_costs, 2)
|
| 77 |
-
margin = round((net_proceeds / selling_price * 100) if selling_price > 0 else 0, 1)
|
| 78 |
-
roi = round((net_proceeds / (product_cost + misc_cost) * 100) if (product_cost + misc_cost) > 0 else 0, 1)
|
| 79 |
-
breakeven = round(total_costs - (shipping_charge if mode == "fbm" else 0.0), 2)
|
| 80 |
-
|
| 81 |
-
return {
|
| 82 |
-
"fulfillment_mode": mode,
|
| 83 |
-
"item_price": selling_price,
|
| 84 |
-
"selling_price": selling_price,
|
| 85 |
-
"shipping_charge": shipping_charge if mode == "fbm" else 0.0,
|
| 86 |
-
"sales_price": round(effective_revenue, 2),
|
| 87 |
-
"amazon_fees": {
|
| 88 |
-
**referral,
|
| 89 |
-
"total": amazon_fees_total,
|
| 90 |
-
},
|
| 91 |
-
"amazon_fees_total": amazon_fees_total,
|
| 92 |
-
"referral_fee": referral["referral_fee"],
|
| 93 |
-
"referral_rate_percent": referral["referral_rate_percent"],
|
| 94 |
-
"fixed_closing_fee": referral["fixed_closing_fee"],
|
| 95 |
-
"variable_closing_fee": referral["variable_closing_fee"],
|
| 96 |
-
"digital_services_fee": referral["digital_services_fee"],
|
| 97 |
-
"fulfillment": fulfillment_block,
|
| 98 |
-
"fba_fee": fulfillment_block.get("fba_fulfillment_fee", 0),
|
| 99 |
-
"fulfillment_cost": fulfillment_block.get("fulfillment_cost", fulfillment_total),
|
| 100 |
-
"inbound": inbound_block,
|
| 101 |
-
"inbound_shipping": inbound_per_unit,
|
| 102 |
-
"storage": storage,
|
| 103 |
-
"storage_cost_per_unit": storage_per_unit,
|
| 104 |
-
"removal_disposal": removal_block if mode == "fba" else None,
|
| 105 |
-
"removal_disposal_cost_per_unit": removal_per_unit,
|
| 106 |
-
"other_costs": {
|
| 107 |
-
"cost_of_goods_sold": round(product_cost, 2),
|
| 108 |
-
"miscellaneous_cost": round(misc_cost, 2),
|
| 109 |
-
"total": round(product_cost + misc_cost, 2),
|
| 110 |
-
},
|
| 111 |
-
"product_cost": round(product_cost, 2),
|
| 112 |
-
"misc_cost": round(misc_cost, 2),
|
| 113 |
-
"total_fees": amazon_fees_total,
|
| 114 |
-
"total_costs": total_costs,
|
| 115 |
-
"cost_per_unit": total_costs,
|
| 116 |
-
"net_proceeds": net_proceeds,
|
| 117 |
-
"gross_profit": net_proceeds,
|
| 118 |
-
"net_profit": net_proceeds,
|
| 119 |
-
"profit_margin_percent": margin,
|
| 120 |
-
"net_margin_percent": margin,
|
| 121 |
-
"roi_percent": roi,
|
| 122 |
-
"breakeven_price": breakeven,
|
| 123 |
-
"is_profitable": net_proceeds > 0,
|
| 124 |
-
"verdict": (
|
| 125 |
-
"Profitable"
|
| 126 |
-
if net_proceeds > 0 and margin >= 20
|
| 127 |
-
else "Low Margin"
|
| 128 |
-
if net_proceeds > 0
|
| 129 |
-
else "Not Profitable"
|
| 130 |
-
),
|
| 131 |
-
"formula_source": "amazon_us_2025",
|
| 132 |
-
"dimensions": {
|
| 133 |
-
"weight_lbs": dims.weight_lbs,
|
| 134 |
-
"length_in": dims.length_in,
|
| 135 |
-
"width_in": dims.width_in,
|
| 136 |
-
"height_in": dims.height_in,
|
| 137 |
-
"cubic_feet": round(dims.cubic_feet, 4),
|
| 138 |
-
},
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
def calculate_profit(
|
| 143 |
-
selling_price: float,
|
| 144 |
-
product_cost: float,
|
| 145 |
-
category: str = "default",
|
| 146 |
-
weight_lbs: float = 1.0,
|
| 147 |
-
length_in: Optional[float] = None,
|
| 148 |
-
width_in: Optional[float] = None,
|
| 149 |
-
height_in: Optional[float] = None,
|
| 150 |
-
shipping_to_fba: float = 0.56,
|
| 151 |
-
additional_costs: float = 0.0,
|
| 152 |
-
fulfillment_mode: FulfillmentMode = "fba",
|
| 153 |
-
fbm_fulfillment_cost: float = 0.0,
|
| 154 |
-
storage_cost_per_unit: float = 0.0,
|
| 155 |
-
shipping_charge: float = 0.0,
|
| 156 |
-
estimated_units: int = 1,
|
| 157 |
-
season: Season = "standard",
|
| 158 |
-
inbound_region: str = "west",
|
| 159 |
-
avg_inventory_units: float = 1.0,
|
| 160 |
-
monthly_units_sold: float = 1.0,
|
| 161 |
-
units_per_shipment: int = 1,
|
| 162 |
-
removal_units: int = 0,
|
| 163 |
-
disposal_units: int = 0,
|
| 164 |
-
) -> Dict:
|
| 165 |
-
if selling_price <= 0:
|
| 166 |
-
return {"error": "Invalid selling price"}
|
| 167 |
-
|
| 168 |
-
if length_in and width_in and height_in:
|
| 169 |
-
dims = ProductDimensions(weight_lbs=weight_lbs, length_in=length_in, width_in=width_in, height_in=height_in)
|
| 170 |
-
else:
|
| 171 |
-
dims = estimate_dimensions_from_weight(weight_lbs)
|
| 172 |
-
|
| 173 |
-
mode: FulfillmentMode = "fbm" if fulfillment_mode == "fbm" else "fba"
|
| 174 |
-
result = _build_fee_section(
|
| 175 |
-
selling_price,
|
| 176 |
-
category,
|
| 177 |
-
dims,
|
| 178 |
-
mode,
|
| 179 |
-
season=season,
|
| 180 |
-
inbound_region=inbound_region,
|
| 181 |
-
shipping_cost_per_shipment=shipping_to_fba if mode == "fba" else 0.0,
|
| 182 |
-
units_per_shipment=units_per_shipment,
|
| 183 |
-
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 184 |
-
avg_inventory_units=avg_inventory_units,
|
| 185 |
-
monthly_units_sold=monthly_units_sold,
|
| 186 |
-
product_cost=product_cost,
|
| 187 |
-
misc_cost=additional_costs,
|
| 188 |
-
shipping_charge=shipping_charge,
|
| 189 |
-
removal_units=removal_units,
|
| 190 |
-
disposal_units=disposal_units,
|
| 191 |
-
)
|
| 192 |
-
|
| 193 |
-
if mode == "fba" and storage_cost_per_unit > 0 and result["storage"]["storage_cost_per_unit_sold"] == 0:
|
| 194 |
-
result["storage"]["storage_cost_per_unit_sold"] = storage_cost_per_unit
|
| 195 |
-
result["storage_cost_per_unit"] = storage_cost_per_unit
|
| 196 |
-
result["total_costs"] = round(result["total_costs"] + storage_cost_per_unit, 2)
|
| 197 |
-
result["cost_per_unit"] = result["total_costs"]
|
| 198 |
-
result["net_proceeds"] = round(result["sales_price"] - result["total_costs"], 2)
|
| 199 |
-
result["net_profit"] = result["net_proceeds"]
|
| 200 |
-
result["gross_profit"] = result["net_proceeds"]
|
| 201 |
-
result["profit_margin_percent"] = round(
|
| 202 |
-
(result["net_proceeds"] / selling_price * 100) if selling_price > 0 else 0, 1
|
| 203 |
-
)
|
| 204 |
-
result["net_margin_percent"] = result["profit_margin_percent"]
|
| 205 |
-
|
| 206 |
-
units = max(int(estimated_units), 1)
|
| 207 |
-
result["estimated_units"] = units
|
| 208 |
-
result["net_total"] = round(result["net_proceeds"] * units, 2)
|
| 209 |
-
return result
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
def compare_fba_fbm(
|
| 213 |
-
selling_price: float,
|
| 214 |
-
product_cost: float,
|
| 215 |
-
category: str = "default",
|
| 216 |
-
weight_lbs: float = 1.0,
|
| 217 |
-
length_in: Optional[float] = None,
|
| 218 |
-
width_in: Optional[float] = None,
|
| 219 |
-
height_in: Optional[float] = None,
|
| 220 |
-
shipping_to_fba: float = 0.0,
|
| 221 |
-
fbm_fulfillment_cost: float = 0.0,
|
| 222 |
-
storage_cost_per_unit_fba: float = 0.0,
|
| 223 |
-
storage_cost_per_unit_fbm: float = 0.0,
|
| 224 |
-
misc_cost: float = 0.0,
|
| 225 |
-
shipping_charge: float = 0.0,
|
| 226 |
-
estimated_units: int = 1,
|
| 227 |
-
season: Season = "standard",
|
| 228 |
-
inbound_region: str = "west",
|
| 229 |
-
avg_inventory_units: float = 1.0,
|
| 230 |
-
monthly_units_sold: float = 1.0,
|
| 231 |
-
units_per_shipment: int = 1,
|
| 232 |
-
removal_units: int = 0,
|
| 233 |
-
disposal_units: int = 0,
|
| 234 |
-
) -> Dict:
|
| 235 |
-
fba = calculate_profit(
|
| 236 |
-
selling_price=selling_price,
|
| 237 |
-
product_cost=product_cost,
|
| 238 |
-
category=category,
|
| 239 |
-
weight_lbs=weight_lbs,
|
| 240 |
-
length_in=length_in,
|
| 241 |
-
width_in=width_in,
|
| 242 |
-
height_in=height_in,
|
| 243 |
-
shipping_to_fba=shipping_to_fba,
|
| 244 |
-
additional_costs=misc_cost,
|
| 245 |
-
fulfillment_mode="fba",
|
| 246 |
-
storage_cost_per_unit=storage_cost_per_unit_fba,
|
| 247 |
-
season=season,
|
| 248 |
-
inbound_region=inbound_region,
|
| 249 |
-
avg_inventory_units=avg_inventory_units,
|
| 250 |
-
monthly_units_sold=monthly_units_sold,
|
| 251 |
-
units_per_shipment=units_per_shipment,
|
| 252 |
-
estimated_units=estimated_units,
|
| 253 |
-
removal_units=removal_units,
|
| 254 |
-
disposal_units=disposal_units,
|
| 255 |
-
)
|
| 256 |
-
fbm = calculate_profit(
|
| 257 |
-
selling_price=selling_price,
|
| 258 |
-
product_cost=product_cost,
|
| 259 |
-
category=category,
|
| 260 |
-
weight_lbs=weight_lbs,
|
| 261 |
-
length_in=length_in,
|
| 262 |
-
width_in=width_in,
|
| 263 |
-
height_in=height_in,
|
| 264 |
-
additional_costs=misc_cost,
|
| 265 |
-
fulfillment_mode="fbm",
|
| 266 |
-
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 267 |
-
storage_cost_per_unit=storage_cost_per_unit_fbm,
|
| 268 |
-
shipping_charge=shipping_charge,
|
| 269 |
-
season=season,
|
| 270 |
-
avg_inventory_units=avg_inventory_units,
|
| 271 |
-
monthly_units_sold=monthly_units_sold,
|
| 272 |
-
estimated_units=estimated_units,
|
| 273 |
-
)
|
| 274 |
-
|
| 275 |
-
if fba.get("error") or fbm.get("error"):
|
| 276 |
-
return {"error": fba.get("error") or fbm.get("error")}
|
| 277 |
-
|
| 278 |
-
better = "fba" if fba["net_profit"] >= fbm["net_profit"] else "fbm"
|
| 279 |
-
margin_delta = round(fbm["net_margin_percent"] - fba["net_margin_percent"], 1)
|
| 280 |
-
|
| 281 |
-
return {
|
| 282 |
-
"selling_price": selling_price,
|
| 283 |
-
"category": category,
|
| 284 |
-
"estimated_units": estimated_units,
|
| 285 |
-
"season": season,
|
| 286 |
-
"inbound_region": inbound_region,
|
| 287 |
-
"formula_source": "amazon_us_2025",
|
| 288 |
-
"fba": fba,
|
| 289 |
-
"fbm": fbm,
|
| 290 |
-
"better_option": better,
|
| 291 |
-
"margin_delta_fbm_vs_fba": margin_delta,
|
| 292 |
-
"comparison_chart": [
|
| 293 |
-
{
|
| 294 |
-
"label": "Amazon Fulfillment (FBA)",
|
| 295 |
-
"net_profit": fba["net_profit"],
|
| 296 |
-
"net_margin": fba["net_margin_percent"],
|
| 297 |
-
"cost_per_unit": fba["cost_per_unit"],
|
| 298 |
-
"amazon_fees": fba["amazon_fees_total"],
|
| 299 |
-
},
|
| 300 |
-
{
|
| 301 |
-
"label": "Your Fulfillment (FBM)",
|
| 302 |
-
"net_profit": fbm["net_profit"],
|
| 303 |
-
"net_margin": fbm["net_margin_percent"],
|
| 304 |
-
"cost_per_unit": fbm["cost_per_unit"],
|
| 305 |
-
"amazon_fees": fbm["amazon_fees_total"],
|
| 306 |
-
},
|
| 307 |
-
],
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
def calculate_storage_cost_per_unit(
|
| 312 |
-
monthly_storage_per_unit: float = 0.0,
|
| 313 |
-
avg_inventory_units: float = 1.0,
|
| 314 |
-
monthly_units_sold: float = 1.0,
|
| 315 |
-
) -> float:
|
| 316 |
-
if monthly_units_sold <= 0:
|
| 317 |
-
return 0.0
|
| 318 |
-
return round((monthly_storage_per_unit * max(avg_inventory_units, 0)) / monthly_units_sold, 2)
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
def build_profit_history_series(
|
| 322 |
-
price_history: list,
|
| 323 |
-
category: str = "default",
|
| 324 |
-
weight_lbs: float = 1.0,
|
| 325 |
-
product_cost: float = 0.0,
|
| 326 |
-
misc_cost: float = 0.0,
|
| 327 |
-
season: Season = "standard",
|
| 328 |
-
) -> list:
|
| 329 |
-
"""FBA vs FBM net profit at each historical price point (Seller Central comparison over time)."""
|
| 330 |
-
series = []
|
| 331 |
-
for row in price_history:
|
| 332 |
-
price = row.get("price")
|
| 333 |
-
if not price or float(price) <= 0:
|
| 334 |
-
continue
|
| 335 |
-
p = float(price)
|
| 336 |
-
cmp = compare_fba_fbm(
|
| 337 |
-
selling_price=p,
|
| 338 |
-
product_cost=product_cost,
|
| 339 |
-
category=category,
|
| 340 |
-
weight_lbs=weight_lbs,
|
| 341 |
-
misc_cost=misc_cost,
|
| 342 |
-
season=season,
|
| 343 |
-
estimated_units=1,
|
| 344 |
-
)
|
| 345 |
-
if cmp.get("error"):
|
| 346 |
-
continue
|
| 347 |
-
series.append(
|
| 348 |
-
{
|
| 349 |
-
"recorded_at": row.get("recorded_at"),
|
| 350 |
-
"date": (row.get("recorded_at") or "")[:10],
|
| 351 |
-
"price": p,
|
| 352 |
-
"fba_net_profit": cmp["fba"]["net_profit"],
|
| 353 |
-
"fbm_net_profit": cmp["fbm"]["net_profit"],
|
| 354 |
-
"fba_margin": cmp["fba"]["net_margin_percent"],
|
| 355 |
-
"fbm_margin": cmp["fbm"]["net_margin_percent"],
|
| 356 |
-
"fba_cost_per_unit": cmp["fba"]["cost_per_unit"],
|
| 357 |
-
"fbm_cost_per_unit": cmp["fbm"]["cost_per_unit"],
|
| 358 |
-
}
|
| 359 |
-
)
|
| 360 |
-
return series
|
|
|
|
| 1 |
+
"""Amazon Seller Central–style revenue / profit calculator (FBA vs FBM)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Dict, Literal, Optional
|
| 5 |
+
|
| 6 |
+
from app.services.analytics.amazon_fees import (
|
| 7 |
+
ProductDimensions,
|
| 8 |
+
calculate_fba_fulfillment_fee,
|
| 9 |
+
calculate_inbound_fees,
|
| 10 |
+
calculate_referral_fee,
|
| 11 |
+
calculate_removal_disposal_cost,
|
| 12 |
+
calculate_storage_fee_per_unit,
|
| 13 |
+
estimate_dimensions_from_weight,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
FulfillmentMode = Literal["fba", "fbm"]
|
| 17 |
+
Season = Literal["standard", "peak"]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _build_fee_section(
|
| 21 |
+
selling_price: float,
|
| 22 |
+
category: str,
|
| 23 |
+
dims: ProductDimensions,
|
| 24 |
+
mode: FulfillmentMode,
|
| 25 |
+
*,
|
| 26 |
+
season: Season = "standard",
|
| 27 |
+
inbound_region: str = "west",
|
| 28 |
+
shipping_cost_per_shipment: float = 0.0,
|
| 29 |
+
units_per_shipment: int = 1,
|
| 30 |
+
fbm_fulfillment_cost: float = 0.0,
|
| 31 |
+
avg_inventory_units: float = 1.0,
|
| 32 |
+
monthly_units_sold: float = 1.0,
|
| 33 |
+
product_cost: float = 0.0,
|
| 34 |
+
misc_cost: float = 0.0,
|
| 35 |
+
shipping_charge: float = 0.0,
|
| 36 |
+
removal_units: int = 0,
|
| 37 |
+
disposal_units: int = 0,
|
| 38 |
+
) -> Dict:
|
| 39 |
+
referral = calculate_referral_fee(selling_price, category)
|
| 40 |
+
amazon_fees_total = round(
|
| 41 |
+
referral["referral_fee"]
|
| 42 |
+
+ referral["fixed_closing_fee"]
|
| 43 |
+
+ referral["variable_closing_fee"]
|
| 44 |
+
+ referral["digital_services_fee"],
|
| 45 |
+
2,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
fulfillment_block: Dict = {"fulfillment_cost": 0.0, "fba_fulfillment_fee": 0.0}
|
| 49 |
+
inbound_block: Dict = {"total_inbound_cost_per_unit": 0.0}
|
| 50 |
+
storage = calculate_storage_fee_per_unit(dims, season, avg_inventory_units, monthly_units_sold)
|
| 51 |
+
|
| 52 |
+
if mode == "fba":
|
| 53 |
+
fulfillment_block = calculate_fba_fulfillment_fee(dims.weight_lbs)
|
| 54 |
+
inbound_block = calculate_inbound_fees(inbound_region, shipping_cost_per_shipment, units_per_shipment)
|
| 55 |
+
fulfillment_total = fulfillment_block["fba_fulfillment_fee"]
|
| 56 |
+
else:
|
| 57 |
+
fulfillment_total = round(fbm_fulfillment_cost, 2)
|
| 58 |
+
fulfillment_block = {"fulfillment_cost": fulfillment_total, "fba_fulfillment_fee": 0.0}
|
| 59 |
+
|
| 60 |
+
storage_per_unit = storage["storage_cost_per_unit_sold"]
|
| 61 |
+
inbound_per_unit = inbound_block["total_inbound_cost_per_unit"] if mode == "fba" else 0.0
|
| 62 |
+
removal_block = calculate_removal_disposal_cost(removal_units, disposal_units, monthly_units_sold)
|
| 63 |
+
removal_per_unit = removal_block["removal_disposal_cost_per_unit_sold"] if mode == "fba" else 0.0
|
| 64 |
+
|
| 65 |
+
effective_revenue = selling_price + (shipping_charge if mode == "fbm" else 0.0)
|
| 66 |
+
total_costs = round(
|
| 67 |
+
amazon_fees_total
|
| 68 |
+
+ fulfillment_total
|
| 69 |
+
+ inbound_per_unit
|
| 70 |
+
+ storage_per_unit
|
| 71 |
+
+ removal_per_unit
|
| 72 |
+
+ product_cost
|
| 73 |
+
+ misc_cost,
|
| 74 |
+
2,
|
| 75 |
+
)
|
| 76 |
+
net_proceeds = round(effective_revenue - total_costs, 2)
|
| 77 |
+
margin = round((net_proceeds / selling_price * 100) if selling_price > 0 else 0, 1)
|
| 78 |
+
roi = round((net_proceeds / (product_cost + misc_cost) * 100) if (product_cost + misc_cost) > 0 else 0, 1)
|
| 79 |
+
breakeven = round(total_costs - (shipping_charge if mode == "fbm" else 0.0), 2)
|
| 80 |
+
|
| 81 |
+
return {
|
| 82 |
+
"fulfillment_mode": mode,
|
| 83 |
+
"item_price": selling_price,
|
| 84 |
+
"selling_price": selling_price,
|
| 85 |
+
"shipping_charge": shipping_charge if mode == "fbm" else 0.0,
|
| 86 |
+
"sales_price": round(effective_revenue, 2),
|
| 87 |
+
"amazon_fees": {
|
| 88 |
+
**referral,
|
| 89 |
+
"total": amazon_fees_total,
|
| 90 |
+
},
|
| 91 |
+
"amazon_fees_total": amazon_fees_total,
|
| 92 |
+
"referral_fee": referral["referral_fee"],
|
| 93 |
+
"referral_rate_percent": referral["referral_rate_percent"],
|
| 94 |
+
"fixed_closing_fee": referral["fixed_closing_fee"],
|
| 95 |
+
"variable_closing_fee": referral["variable_closing_fee"],
|
| 96 |
+
"digital_services_fee": referral["digital_services_fee"],
|
| 97 |
+
"fulfillment": fulfillment_block,
|
| 98 |
+
"fba_fee": fulfillment_block.get("fba_fulfillment_fee", 0),
|
| 99 |
+
"fulfillment_cost": fulfillment_block.get("fulfillment_cost", fulfillment_total),
|
| 100 |
+
"inbound": inbound_block,
|
| 101 |
+
"inbound_shipping": inbound_per_unit,
|
| 102 |
+
"storage": storage,
|
| 103 |
+
"storage_cost_per_unit": storage_per_unit,
|
| 104 |
+
"removal_disposal": removal_block if mode == "fba" else None,
|
| 105 |
+
"removal_disposal_cost_per_unit": removal_per_unit,
|
| 106 |
+
"other_costs": {
|
| 107 |
+
"cost_of_goods_sold": round(product_cost, 2),
|
| 108 |
+
"miscellaneous_cost": round(misc_cost, 2),
|
| 109 |
+
"total": round(product_cost + misc_cost, 2),
|
| 110 |
+
},
|
| 111 |
+
"product_cost": round(product_cost, 2),
|
| 112 |
+
"misc_cost": round(misc_cost, 2),
|
| 113 |
+
"total_fees": amazon_fees_total,
|
| 114 |
+
"total_costs": total_costs,
|
| 115 |
+
"cost_per_unit": total_costs,
|
| 116 |
+
"net_proceeds": net_proceeds,
|
| 117 |
+
"gross_profit": net_proceeds,
|
| 118 |
+
"net_profit": net_proceeds,
|
| 119 |
+
"profit_margin_percent": margin,
|
| 120 |
+
"net_margin_percent": margin,
|
| 121 |
+
"roi_percent": roi,
|
| 122 |
+
"breakeven_price": breakeven,
|
| 123 |
+
"is_profitable": net_proceeds > 0,
|
| 124 |
+
"verdict": (
|
| 125 |
+
"Profitable"
|
| 126 |
+
if net_proceeds > 0 and margin >= 20
|
| 127 |
+
else "Low Margin"
|
| 128 |
+
if net_proceeds > 0
|
| 129 |
+
else "Not Profitable"
|
| 130 |
+
),
|
| 131 |
+
"formula_source": "amazon_us_2025",
|
| 132 |
+
"dimensions": {
|
| 133 |
+
"weight_lbs": dims.weight_lbs,
|
| 134 |
+
"length_in": dims.length_in,
|
| 135 |
+
"width_in": dims.width_in,
|
| 136 |
+
"height_in": dims.height_in,
|
| 137 |
+
"cubic_feet": round(dims.cubic_feet, 4),
|
| 138 |
+
},
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def calculate_profit(
|
| 143 |
+
selling_price: float,
|
| 144 |
+
product_cost: float,
|
| 145 |
+
category: str = "default",
|
| 146 |
+
weight_lbs: float = 1.0,
|
| 147 |
+
length_in: Optional[float] = None,
|
| 148 |
+
width_in: Optional[float] = None,
|
| 149 |
+
height_in: Optional[float] = None,
|
| 150 |
+
shipping_to_fba: float = 0.56,
|
| 151 |
+
additional_costs: float = 0.0,
|
| 152 |
+
fulfillment_mode: FulfillmentMode = "fba",
|
| 153 |
+
fbm_fulfillment_cost: float = 0.0,
|
| 154 |
+
storage_cost_per_unit: float = 0.0,
|
| 155 |
+
shipping_charge: float = 0.0,
|
| 156 |
+
estimated_units: int = 1,
|
| 157 |
+
season: Season = "standard",
|
| 158 |
+
inbound_region: str = "west",
|
| 159 |
+
avg_inventory_units: float = 1.0,
|
| 160 |
+
monthly_units_sold: float = 1.0,
|
| 161 |
+
units_per_shipment: int = 1,
|
| 162 |
+
removal_units: int = 0,
|
| 163 |
+
disposal_units: int = 0,
|
| 164 |
+
) -> Dict:
|
| 165 |
+
if selling_price <= 0:
|
| 166 |
+
return {"error": "Invalid selling price"}
|
| 167 |
+
|
| 168 |
+
if length_in and width_in and height_in:
|
| 169 |
+
dims = ProductDimensions(weight_lbs=weight_lbs, length_in=length_in, width_in=width_in, height_in=height_in)
|
| 170 |
+
else:
|
| 171 |
+
dims = estimate_dimensions_from_weight(weight_lbs)
|
| 172 |
+
|
| 173 |
+
mode: FulfillmentMode = "fbm" if fulfillment_mode == "fbm" else "fba"
|
| 174 |
+
result = _build_fee_section(
|
| 175 |
+
selling_price,
|
| 176 |
+
category,
|
| 177 |
+
dims,
|
| 178 |
+
mode,
|
| 179 |
+
season=season,
|
| 180 |
+
inbound_region=inbound_region,
|
| 181 |
+
shipping_cost_per_shipment=shipping_to_fba if mode == "fba" else 0.0,
|
| 182 |
+
units_per_shipment=units_per_shipment,
|
| 183 |
+
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 184 |
+
avg_inventory_units=avg_inventory_units,
|
| 185 |
+
monthly_units_sold=monthly_units_sold,
|
| 186 |
+
product_cost=product_cost,
|
| 187 |
+
misc_cost=additional_costs,
|
| 188 |
+
shipping_charge=shipping_charge,
|
| 189 |
+
removal_units=removal_units,
|
| 190 |
+
disposal_units=disposal_units,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if mode == "fba" and storage_cost_per_unit > 0 and result["storage"]["storage_cost_per_unit_sold"] == 0:
|
| 194 |
+
result["storage"]["storage_cost_per_unit_sold"] = storage_cost_per_unit
|
| 195 |
+
result["storage_cost_per_unit"] = storage_cost_per_unit
|
| 196 |
+
result["total_costs"] = round(result["total_costs"] + storage_cost_per_unit, 2)
|
| 197 |
+
result["cost_per_unit"] = result["total_costs"]
|
| 198 |
+
result["net_proceeds"] = round(result["sales_price"] - result["total_costs"], 2)
|
| 199 |
+
result["net_profit"] = result["net_proceeds"]
|
| 200 |
+
result["gross_profit"] = result["net_proceeds"]
|
| 201 |
+
result["profit_margin_percent"] = round(
|
| 202 |
+
(result["net_proceeds"] / selling_price * 100) if selling_price > 0 else 0, 1
|
| 203 |
+
)
|
| 204 |
+
result["net_margin_percent"] = result["profit_margin_percent"]
|
| 205 |
+
|
| 206 |
+
units = max(int(estimated_units), 1)
|
| 207 |
+
result["estimated_units"] = units
|
| 208 |
+
result["net_total"] = round(result["net_proceeds"] * units, 2)
|
| 209 |
+
return result
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def compare_fba_fbm(
|
| 213 |
+
selling_price: float,
|
| 214 |
+
product_cost: float,
|
| 215 |
+
category: str = "default",
|
| 216 |
+
weight_lbs: float = 1.0,
|
| 217 |
+
length_in: Optional[float] = None,
|
| 218 |
+
width_in: Optional[float] = None,
|
| 219 |
+
height_in: Optional[float] = None,
|
| 220 |
+
shipping_to_fba: float = 0.0,
|
| 221 |
+
fbm_fulfillment_cost: float = 0.0,
|
| 222 |
+
storage_cost_per_unit_fba: float = 0.0,
|
| 223 |
+
storage_cost_per_unit_fbm: float = 0.0,
|
| 224 |
+
misc_cost: float = 0.0,
|
| 225 |
+
shipping_charge: float = 0.0,
|
| 226 |
+
estimated_units: int = 1,
|
| 227 |
+
season: Season = "standard",
|
| 228 |
+
inbound_region: str = "west",
|
| 229 |
+
avg_inventory_units: float = 1.0,
|
| 230 |
+
monthly_units_sold: float = 1.0,
|
| 231 |
+
units_per_shipment: int = 1,
|
| 232 |
+
removal_units: int = 0,
|
| 233 |
+
disposal_units: int = 0,
|
| 234 |
+
) -> Dict:
|
| 235 |
+
fba = calculate_profit(
|
| 236 |
+
selling_price=selling_price,
|
| 237 |
+
product_cost=product_cost,
|
| 238 |
+
category=category,
|
| 239 |
+
weight_lbs=weight_lbs,
|
| 240 |
+
length_in=length_in,
|
| 241 |
+
width_in=width_in,
|
| 242 |
+
height_in=height_in,
|
| 243 |
+
shipping_to_fba=shipping_to_fba,
|
| 244 |
+
additional_costs=misc_cost,
|
| 245 |
+
fulfillment_mode="fba",
|
| 246 |
+
storage_cost_per_unit=storage_cost_per_unit_fba,
|
| 247 |
+
season=season,
|
| 248 |
+
inbound_region=inbound_region,
|
| 249 |
+
avg_inventory_units=avg_inventory_units,
|
| 250 |
+
monthly_units_sold=monthly_units_sold,
|
| 251 |
+
units_per_shipment=units_per_shipment,
|
| 252 |
+
estimated_units=estimated_units,
|
| 253 |
+
removal_units=removal_units,
|
| 254 |
+
disposal_units=disposal_units,
|
| 255 |
+
)
|
| 256 |
+
fbm = calculate_profit(
|
| 257 |
+
selling_price=selling_price,
|
| 258 |
+
product_cost=product_cost,
|
| 259 |
+
category=category,
|
| 260 |
+
weight_lbs=weight_lbs,
|
| 261 |
+
length_in=length_in,
|
| 262 |
+
width_in=width_in,
|
| 263 |
+
height_in=height_in,
|
| 264 |
+
additional_costs=misc_cost,
|
| 265 |
+
fulfillment_mode="fbm",
|
| 266 |
+
fbm_fulfillment_cost=fbm_fulfillment_cost,
|
| 267 |
+
storage_cost_per_unit=storage_cost_per_unit_fbm,
|
| 268 |
+
shipping_charge=shipping_charge,
|
| 269 |
+
season=season,
|
| 270 |
+
avg_inventory_units=avg_inventory_units,
|
| 271 |
+
monthly_units_sold=monthly_units_sold,
|
| 272 |
+
estimated_units=estimated_units,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
if fba.get("error") or fbm.get("error"):
|
| 276 |
+
return {"error": fba.get("error") or fbm.get("error")}
|
| 277 |
+
|
| 278 |
+
better = "fba" if fba["net_profit"] >= fbm["net_profit"] else "fbm"
|
| 279 |
+
margin_delta = round(fbm["net_margin_percent"] - fba["net_margin_percent"], 1)
|
| 280 |
+
|
| 281 |
+
return {
|
| 282 |
+
"selling_price": selling_price,
|
| 283 |
+
"category": category,
|
| 284 |
+
"estimated_units": estimated_units,
|
| 285 |
+
"season": season,
|
| 286 |
+
"inbound_region": inbound_region,
|
| 287 |
+
"formula_source": "amazon_us_2025",
|
| 288 |
+
"fba": fba,
|
| 289 |
+
"fbm": fbm,
|
| 290 |
+
"better_option": better,
|
| 291 |
+
"margin_delta_fbm_vs_fba": margin_delta,
|
| 292 |
+
"comparison_chart": [
|
| 293 |
+
{
|
| 294 |
+
"label": "Amazon Fulfillment (FBA)",
|
| 295 |
+
"net_profit": fba["net_profit"],
|
| 296 |
+
"net_margin": fba["net_margin_percent"],
|
| 297 |
+
"cost_per_unit": fba["cost_per_unit"],
|
| 298 |
+
"amazon_fees": fba["amazon_fees_total"],
|
| 299 |
+
},
|
| 300 |
+
{
|
| 301 |
+
"label": "Your Fulfillment (FBM)",
|
| 302 |
+
"net_profit": fbm["net_profit"],
|
| 303 |
+
"net_margin": fbm["net_margin_percent"],
|
| 304 |
+
"cost_per_unit": fbm["cost_per_unit"],
|
| 305 |
+
"amazon_fees": fbm["amazon_fees_total"],
|
| 306 |
+
},
|
| 307 |
+
],
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def calculate_storage_cost_per_unit(
|
| 312 |
+
monthly_storage_per_unit: float = 0.0,
|
| 313 |
+
avg_inventory_units: float = 1.0,
|
| 314 |
+
monthly_units_sold: float = 1.0,
|
| 315 |
+
) -> float:
|
| 316 |
+
if monthly_units_sold <= 0:
|
| 317 |
+
return 0.0
|
| 318 |
+
return round((monthly_storage_per_unit * max(avg_inventory_units, 0)) / monthly_units_sold, 2)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def build_profit_history_series(
|
| 322 |
+
price_history: list,
|
| 323 |
+
category: str = "default",
|
| 324 |
+
weight_lbs: float = 1.0,
|
| 325 |
+
product_cost: float = 0.0,
|
| 326 |
+
misc_cost: float = 0.0,
|
| 327 |
+
season: Season = "standard",
|
| 328 |
+
) -> list:
|
| 329 |
+
"""FBA vs FBM net profit at each historical price point (Seller Central comparison over time)."""
|
| 330 |
+
series = []
|
| 331 |
+
for row in price_history:
|
| 332 |
+
price = row.get("price")
|
| 333 |
+
if not price or float(price) <= 0:
|
| 334 |
+
continue
|
| 335 |
+
p = float(price)
|
| 336 |
+
cmp = compare_fba_fbm(
|
| 337 |
+
selling_price=p,
|
| 338 |
+
product_cost=product_cost,
|
| 339 |
+
category=category,
|
| 340 |
+
weight_lbs=weight_lbs,
|
| 341 |
+
misc_cost=misc_cost,
|
| 342 |
+
season=season,
|
| 343 |
+
estimated_units=1,
|
| 344 |
+
)
|
| 345 |
+
if cmp.get("error"):
|
| 346 |
+
continue
|
| 347 |
+
series.append(
|
| 348 |
+
{
|
| 349 |
+
"recorded_at": row.get("recorded_at"),
|
| 350 |
+
"date": (row.get("recorded_at") or "")[:10],
|
| 351 |
+
"price": p,
|
| 352 |
+
"fba_net_profit": cmp["fba"]["net_profit"],
|
| 353 |
+
"fbm_net_profit": cmp["fbm"]["net_profit"],
|
| 354 |
+
"fba_margin": cmp["fba"]["net_margin_percent"],
|
| 355 |
+
"fbm_margin": cmp["fbm"]["net_margin_percent"],
|
| 356 |
+
"fba_cost_per_unit": cmp["fba"]["cost_per_unit"],
|
| 357 |
+
"fbm_cost_per_unit": cmp["fbm"]["cost_per_unit"],
|
| 358 |
+
}
|
| 359 |
+
)
|
| 360 |
+
return series
|
app/services/analytics/tracking_service.py
CHANGED
|
@@ -1,73 +1,73 @@
|
|
| 1 |
-
from sqlalchemy.orm import Session
|
| 2 |
-
from app.models.product import PriceHistory, TrackedProduct, Product
|
| 3 |
-
from app.models.tracking import PriceAlert
|
| 4 |
-
from datetime import datetime, timezone
|
| 5 |
-
import uuid
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def record_price(db: Session, asin: str, price: float, bsr: int = None, reviews: int = None, rating: float = None):
|
| 9 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 10 |
-
if not product:
|
| 11 |
-
return None
|
| 12 |
-
entry = PriceHistory(
|
| 13 |
-
id=str(uuid.uuid4()),
|
| 14 |
-
product_id=str(product.id),
|
| 15 |
-
price=price,
|
| 16 |
-
bsr=bsr,
|
| 17 |
-
review_count=reviews,
|
| 18 |
-
rating=rating,
|
| 19 |
-
)
|
| 20 |
-
db.add(entry)
|
| 21 |
-
db.commit()
|
| 22 |
-
return entry
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def get_price_history(db: Session, asin: str, days: int = 30):
|
| 26 |
-
from datetime import timedelta
|
| 27 |
-
product = db.query(Product).filter(Product.asin == asin).first()
|
| 28 |
-
if not product:
|
| 29 |
-
return []
|
| 30 |
-
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 31 |
-
rows = db.query(PriceHistory).filter(
|
| 32 |
-
PriceHistory.product_id == product.id,
|
| 33 |
-
PriceHistory.recorded_at >= since
|
| 34 |
-
).order_by(PriceHistory.recorded_at.asc()).all()
|
| 35 |
-
return [{"date": r.recorded_at.isoformat(), "price": float(r.price) if r.price else None, "bsr": r.bsr, "reviews": r.review_count, "rating": float(r.rating) if r.rating else None} for r in rows]
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def create_alert(db: Session, user_id: str, asin: str, alert_type: str, threshold: float):
|
| 39 |
-
alert = PriceAlert(user_id=user_id, asin=asin, alert_type=alert_type, threshold=threshold)
|
| 40 |
-
db.add(alert)
|
| 41 |
-
db.commit()
|
| 42 |
-
return alert
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def get_alerts(db: Session, user_id: str):
|
| 46 |
-
return db.query(PriceAlert).filter(PriceAlert.user_id == user_id, PriceAlert.is_active == True).all()
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
def check_alerts(db: Session, asin: str, current_price: float, current_bsr: int = None):
|
| 50 |
-
alerts = db.query(PriceAlert).filter(PriceAlert.asin == asin, PriceAlert.is_active == True, PriceAlert.triggered == False).all()
|
| 51 |
-
triggered = []
|
| 52 |
-
for alert in alerts:
|
| 53 |
-
if alert.alert_type == "price_drop" and current_price <= alert.threshold:
|
| 54 |
-
alert.triggered = True
|
| 55 |
-
alert.triggered_at = datetime.now(timezone.utc)
|
| 56 |
-
triggered.append({"type": "price_drop", "asin": asin, "price": current_price})
|
| 57 |
-
elif alert.alert_type == "bsr_improve" and current_bsr and current_bsr <= alert.threshold:
|
| 58 |
-
alert.triggered = True
|
| 59 |
-
alert.triggered_at = datetime.now(timezone.utc)
|
| 60 |
-
triggered.append({"type": "bsr_improve", "asin": asin, "bsr": current_bsr})
|
| 61 |
-
db.commit()
|
| 62 |
-
return triggered
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def get_tracked_products(db: Session, user_id: str):
|
| 66 |
-
tracked = db.query(TrackedProduct).filter(TrackedProduct.user_id == user_id).all()
|
| 67 |
-
result = []
|
| 68 |
-
for t in tracked:
|
| 69 |
-
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 70 |
-
if product:
|
| 71 |
-
latest = db.query(PriceHistory).filter(PriceHistory.product_id == product.id).order_by(PriceHistory.recorded_at.desc()).first()
|
| 72 |
-
result.append({"asin": product.asin, "title": product.title, "price": float(latest.price) if latest and latest.price else None, "bsr": latest.bsr if latest else None, "tracked_at": t.tracked_at.isoformat() if t.tracked_at else None})
|
| 73 |
return result
|
|
|
|
| 1 |
+
from sqlalchemy.orm import Session
|
| 2 |
+
from app.models.product import PriceHistory, TrackedProduct, Product
|
| 3 |
+
from app.models.tracking import PriceAlert
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def record_price(db: Session, asin: str, price: float, bsr: int = None, reviews: int = None, rating: float = None):
|
| 9 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 10 |
+
if not product:
|
| 11 |
+
return None
|
| 12 |
+
entry = PriceHistory(
|
| 13 |
+
id=str(uuid.uuid4()),
|
| 14 |
+
product_id=str(product.id),
|
| 15 |
+
price=price,
|
| 16 |
+
bsr=bsr,
|
| 17 |
+
review_count=reviews,
|
| 18 |
+
rating=rating,
|
| 19 |
+
)
|
| 20 |
+
db.add(entry)
|
| 21 |
+
db.commit()
|
| 22 |
+
return entry
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_price_history(db: Session, asin: str, days: int = 30):
|
| 26 |
+
from datetime import timedelta
|
| 27 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 28 |
+
if not product:
|
| 29 |
+
return []
|
| 30 |
+
since = datetime.now(timezone.utc) - timedelta(days=days)
|
| 31 |
+
rows = db.query(PriceHistory).filter(
|
| 32 |
+
PriceHistory.product_id == product.id,
|
| 33 |
+
PriceHistory.recorded_at >= since
|
| 34 |
+
).order_by(PriceHistory.recorded_at.asc()).all()
|
| 35 |
+
return [{"date": r.recorded_at.isoformat(), "price": float(r.price) if r.price else None, "bsr": r.bsr, "reviews": r.review_count, "rating": float(r.rating) if r.rating else None} for r in rows]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def create_alert(db: Session, user_id: str, asin: str, alert_type: str, threshold: float):
|
| 39 |
+
alert = PriceAlert(user_id=user_id, asin=asin, alert_type=alert_type, threshold=threshold)
|
| 40 |
+
db.add(alert)
|
| 41 |
+
db.commit()
|
| 42 |
+
return alert
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_alerts(db: Session, user_id: str):
|
| 46 |
+
return db.query(PriceAlert).filter(PriceAlert.user_id == user_id, PriceAlert.is_active == True).all()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def check_alerts(db: Session, asin: str, current_price: float, current_bsr: int = None):
|
| 50 |
+
alerts = db.query(PriceAlert).filter(PriceAlert.asin == asin, PriceAlert.is_active == True, PriceAlert.triggered == False).all()
|
| 51 |
+
triggered = []
|
| 52 |
+
for alert in alerts:
|
| 53 |
+
if alert.alert_type == "price_drop" and current_price <= alert.threshold:
|
| 54 |
+
alert.triggered = True
|
| 55 |
+
alert.triggered_at = datetime.now(timezone.utc)
|
| 56 |
+
triggered.append({"type": "price_drop", "asin": asin, "price": current_price})
|
| 57 |
+
elif alert.alert_type == "bsr_improve" and current_bsr and current_bsr <= alert.threshold:
|
| 58 |
+
alert.triggered = True
|
| 59 |
+
alert.triggered_at = datetime.now(timezone.utc)
|
| 60 |
+
triggered.append({"type": "bsr_improve", "asin": asin, "bsr": current_bsr})
|
| 61 |
+
db.commit()
|
| 62 |
+
return triggered
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_tracked_products(db: Session, user_id: str):
|
| 66 |
+
tracked = db.query(TrackedProduct).filter(TrackedProduct.user_id == user_id).all()
|
| 67 |
+
result = []
|
| 68 |
+
for t in tracked:
|
| 69 |
+
product = db.query(Product).filter(Product.id == t.product_id).first()
|
| 70 |
+
if product:
|
| 71 |
+
latest = db.query(PriceHistory).filter(PriceHistory.product_id == product.id).order_by(PriceHistory.recorded_at.desc()).first()
|
| 72 |
+
result.append({"asin": product.asin, "title": product.title, "price": float(latest.price) if latest and latest.price else None, "bsr": latest.bsr if latest else None, "tracked_at": t.tracked_at.isoformat() if t.tracked_at else None})
|
| 73 |
return result
|
app/services/llm_service.py
CHANGED
|
@@ -1,198 +1,198 @@
|
|
| 1 |
-
"""Central LLM service for Rankora.
|
| 2 |
-
|
| 3 |
-
Supports Google Gemini (default), OpenAI, and Anthropic.
|
| 4 |
-
Set exactly one API key in backend/.env to enable real LLM responses.
|
| 5 |
-
"""
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import json
|
| 9 |
-
import re
|
| 10 |
-
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
-
|
| 12 |
-
from app.config import settings
|
| 13 |
-
|
| 14 |
-
Message = Dict[str, str]
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def is_llm_available() -> bool:
|
| 18 |
-
return bool(settings.gemini_api_key or settings.openai_api_key or settings.anthropic_api_key)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def get_active_provider() -> Optional[str]:
|
| 22 |
-
if settings.gemini_api_key:
|
| 23 |
-
return "gemini"
|
| 24 |
-
if settings.anthropic_api_key:
|
| 25 |
-
return "anthropic"
|
| 26 |
-
if settings.openai_api_key:
|
| 27 |
-
return "openai"
|
| 28 |
-
return None
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def _model_for(provider: str) -> str:
|
| 32 |
-
model = (settings.ai_model or "").strip()
|
| 33 |
-
if model:
|
| 34 |
-
return model
|
| 35 |
-
defaults = {
|
| 36 |
-
"gemini": "gemini-2.0-flash",
|
| 37 |
-
"anthropic": "claude-3-5-sonnet-latest",
|
| 38 |
-
"openai": "gpt-4o-mini",
|
| 39 |
-
}
|
| 40 |
-
return defaults[provider]
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def generate_text(
|
| 44 |
-
system: str,
|
| 45 |
-
user: str,
|
| 46 |
-
history: Optional[List[Message]] = None,
|
| 47 |
-
*,
|
| 48 |
-
max_tokens: int = 1536,
|
| 49 |
-
temperature: float = 0.35,
|
| 50 |
-
) -> Tuple[Optional[str], Optional[str]]:
|
| 51 |
-
"""Return (text, provider) or (None, None) if no key or all providers fail."""
|
| 52 |
-
history = history or []
|
| 53 |
-
provider = get_active_provider()
|
| 54 |
-
if not provider:
|
| 55 |
-
return None, None
|
| 56 |
-
|
| 57 |
-
if provider == "gemini":
|
| 58 |
-
text = _gemini_text(system, user, history, max_tokens, temperature)
|
| 59 |
-
return (text, "gemini") if text else _fallback_chain(system, user, history, max_tokens, temperature, skip="gemini")
|
| 60 |
-
|
| 61 |
-
if provider == "anthropic":
|
| 62 |
-
text = _anthropic_text(system, user, history, max_tokens, temperature)
|
| 63 |
-
return (text, "anthropic") if text else _fallback_chain(system, user, history, max_tokens, temperature, skip="anthropic")
|
| 64 |
-
|
| 65 |
-
text = _openai_text(system, user, history, max_tokens, temperature)
|
| 66 |
-
return (text, "openai") if text else _fallback_chain(system, user, history, max_tokens, temperature, skip="openai")
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def generate_json(
|
| 70 |
-
system: str,
|
| 71 |
-
user: str,
|
| 72 |
-
*,
|
| 73 |
-
max_tokens: int = 2048,
|
| 74 |
-
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
| 75 |
-
"""Ask the LLM for a JSON object. Returns (parsed_dict, provider)."""
|
| 76 |
-
prompt = (
|
| 77 |
-
f"{user}\n\n"
|
| 78 |
-
"Respond with ONLY valid JSON — no markdown fences, no commentary."
|
| 79 |
-
)
|
| 80 |
-
raw, provider = generate_text(system, prompt, max_tokens=max_tokens, temperature=0.2)
|
| 81 |
-
if not raw:
|
| 82 |
-
return None, None
|
| 83 |
-
parsed = _parse_json_object(raw)
|
| 84 |
-
return parsed, provider
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def _fallback_chain(
|
| 88 |
-
system: str,
|
| 89 |
-
user: str,
|
| 90 |
-
history: List[Message],
|
| 91 |
-
max_tokens: int,
|
| 92 |
-
temperature: float,
|
| 93 |
-
skip: str,
|
| 94 |
-
) -> Tuple[Optional[str], Optional[str]]:
|
| 95 |
-
order = [p for p in ("gemini", "anthropic", "openai") if p != skip]
|
| 96 |
-
for provider in order:
|
| 97 |
-
if provider == "gemini" and not settings.gemini_api_key:
|
| 98 |
-
continue
|
| 99 |
-
if provider == "anthropic" and not settings.anthropic_api_key:
|
| 100 |
-
continue
|
| 101 |
-
if provider == "openai" and not settings.openai_api_key:
|
| 102 |
-
continue
|
| 103 |
-
fn = {"gemini": _gemini_text, "anthropic": _anthropic_text, "openai": _openai_text}[provider]
|
| 104 |
-
text = fn(system, user, history, max_tokens, temperature)
|
| 105 |
-
if text:
|
| 106 |
-
return text, provider
|
| 107 |
-
return None, None
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
def _gemini_text(system: str, user: str, history: List[Message], max_tokens: int, temperature: float) -> Optional[str]:
|
| 111 |
-
if not settings.gemini_api_key:
|
| 112 |
-
return None
|
| 113 |
-
try:
|
| 114 |
-
import google.generativeai as genai
|
| 115 |
-
|
| 116 |
-
genai.configure(api_key=settings.gemini_api_key)
|
| 117 |
-
model = genai.GenerativeModel(
|
| 118 |
-
_model_for("gemini"),
|
| 119 |
-
system_instruction=system,
|
| 120 |
-
)
|
| 121 |
-
chat_history = []
|
| 122 |
-
for msg in history[-8:]:
|
| 123 |
-
role = msg.get("role")
|
| 124 |
-
if role not in ("user", "assistant"):
|
| 125 |
-
continue
|
| 126 |
-
chat_history.append({"role": "user" if role == "user" else "model", "parts": [msg["content"]]})
|
| 127 |
-
chat = model.start_chat(history=chat_history)
|
| 128 |
-
resp = chat.send_message(
|
| 129 |
-
user,
|
| 130 |
-
generation_config={"max_output_tokens": max_tokens, "temperature": temperature},
|
| 131 |
-
)
|
| 132 |
-
return (resp.text or "").strip() or None
|
| 133 |
-
except Exception as e:
|
| 134 |
-
print(f"[llm_service] gemini failed: {e}")
|
| 135 |
-
return None
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def _anthropic_text(system: str, user: str, history: List[Message], max_tokens: int, temperature: float) -> Optional[str]:
|
| 139 |
-
if not settings.anthropic_api_key:
|
| 140 |
-
return None
|
| 141 |
-
try:
|
| 142 |
-
import anthropic
|
| 143 |
-
|
| 144 |
-
client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
| 145 |
-
msgs = [{"role": m["role"], "content": m["content"]} for m in history[-8:] if m.get("role") in ("user", "assistant")]
|
| 146 |
-
msgs.append({"role": "user", "content": user})
|
| 147 |
-
resp = client.messages.create(
|
| 148 |
-
model=_model_for("anthropic"),
|
| 149 |
-
max_tokens=max_tokens,
|
| 150 |
-
temperature=temperature,
|
| 151 |
-
system=system,
|
| 152 |
-
messages=msgs,
|
| 153 |
-
)
|
| 154 |
-
return "".join(getattr(b, "text", "") for b in resp.content).strip() or None
|
| 155 |
-
except Exception as e:
|
| 156 |
-
print(f"[llm_service] anthropic failed: {e}")
|
| 157 |
-
return None
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _openai_text(system: str, user: str, history: List[Message], max_tokens: int, temperature: float) -> Optional[str]:
|
| 161 |
-
if not settings.openai_api_key:
|
| 162 |
-
return None
|
| 163 |
-
try:
|
| 164 |
-
from openai import OpenAI
|
| 165 |
-
|
| 166 |
-
client = OpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url or None)
|
| 167 |
-
msgs: List[Dict[str, str]] = [{"role": "system", "content": system}]
|
| 168 |
-
msgs += [{"role": m["role"], "content": m["content"]} for m in history[-8:] if m.get("role") in ("user", "assistant")]
|
| 169 |
-
msgs.append({"role": "user", "content": user})
|
| 170 |
-
resp = client.chat.completions.create(
|
| 171 |
-
model=_model_for("openai"),
|
| 172 |
-
messages=msgs,
|
| 173 |
-
max_tokens=max_tokens,
|
| 174 |
-
temperature=temperature,
|
| 175 |
-
)
|
| 176 |
-
return (resp.choices[0].message.content or "").strip() or None
|
| 177 |
-
except Exception as e:
|
| 178 |
-
print(f"[llm_service] openai failed: {e}")
|
| 179 |
-
return None
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def _parse_json_object(raw: str) -> Optional[Dict[str, Any]]:
|
| 183 |
-
text = raw.strip()
|
| 184 |
-
fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
| 185 |
-
if fence:
|
| 186 |
-
text = fence.group(1).strip()
|
| 187 |
-
try:
|
| 188 |
-
data = json.loads(text)
|
| 189 |
-
return data if isinstance(data, dict) else None
|
| 190 |
-
except json.JSONDecodeError:
|
| 191 |
-
start, end = text.find("{"), text.rfind("}")
|
| 192 |
-
if start >= 0 and end > start:
|
| 193 |
-
try:
|
| 194 |
-
data = json.loads(text[start : end + 1])
|
| 195 |
-
return data if isinstance(data, dict) else None
|
| 196 |
-
except json.JSONDecodeError:
|
| 197 |
-
return None
|
| 198 |
-
return None
|
|
|
|
| 1 |
+
"""Central LLM service for Rankora.
|
| 2 |
+
|
| 3 |
+
Supports Google Gemini (default), OpenAI, and Anthropic.
|
| 4 |
+
Set exactly one API key in backend/.env to enable real LLM responses.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import re
|
| 10 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
from app.config import settings
|
| 13 |
+
|
| 14 |
+
Message = Dict[str, str]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def is_llm_available() -> bool:
|
| 18 |
+
return bool(settings.gemini_api_key or settings.openai_api_key or settings.anthropic_api_key)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_active_provider() -> Optional[str]:
|
| 22 |
+
if settings.gemini_api_key:
|
| 23 |
+
return "gemini"
|
| 24 |
+
if settings.anthropic_api_key:
|
| 25 |
+
return "anthropic"
|
| 26 |
+
if settings.openai_api_key:
|
| 27 |
+
return "openai"
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _model_for(provider: str) -> str:
|
| 32 |
+
model = (settings.ai_model or "").strip()
|
| 33 |
+
if model:
|
| 34 |
+
return model
|
| 35 |
+
defaults = {
|
| 36 |
+
"gemini": "gemini-2.0-flash",
|
| 37 |
+
"anthropic": "claude-3-5-sonnet-latest",
|
| 38 |
+
"openai": "gpt-4o-mini",
|
| 39 |
+
}
|
| 40 |
+
return defaults[provider]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def generate_text(
|
| 44 |
+
system: str,
|
| 45 |
+
user: str,
|
| 46 |
+
history: Optional[List[Message]] = None,
|
| 47 |
+
*,
|
| 48 |
+
max_tokens: int = 1536,
|
| 49 |
+
temperature: float = 0.35,
|
| 50 |
+
) -> Tuple[Optional[str], Optional[str]]:
|
| 51 |
+
"""Return (text, provider) or (None, None) if no key or all providers fail."""
|
| 52 |
+
history = history or []
|
| 53 |
+
provider = get_active_provider()
|
| 54 |
+
if not provider:
|
| 55 |
+
return None, None
|
| 56 |
+
|
| 57 |
+
if provider == "gemini":
|
| 58 |
+
text = _gemini_text(system, user, history, max_tokens, temperature)
|
| 59 |
+
return (text, "gemini") if text else _fallback_chain(system, user, history, max_tokens, temperature, skip="gemini")
|
| 60 |
+
|
| 61 |
+
if provider == "anthropic":
|
| 62 |
+
text = _anthropic_text(system, user, history, max_tokens, temperature)
|
| 63 |
+
return (text, "anthropic") if text else _fallback_chain(system, user, history, max_tokens, temperature, skip="anthropic")
|
| 64 |
+
|
| 65 |
+
text = _openai_text(system, user, history, max_tokens, temperature)
|
| 66 |
+
return (text, "openai") if text else _fallback_chain(system, user, history, max_tokens, temperature, skip="openai")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def generate_json(
|
| 70 |
+
system: str,
|
| 71 |
+
user: str,
|
| 72 |
+
*,
|
| 73 |
+
max_tokens: int = 2048,
|
| 74 |
+
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
| 75 |
+
"""Ask the LLM for a JSON object. Returns (parsed_dict, provider)."""
|
| 76 |
+
prompt = (
|
| 77 |
+
f"{user}\n\n"
|
| 78 |
+
"Respond with ONLY valid JSON — no markdown fences, no commentary."
|
| 79 |
+
)
|
| 80 |
+
raw, provider = generate_text(system, prompt, max_tokens=max_tokens, temperature=0.2)
|
| 81 |
+
if not raw:
|
| 82 |
+
return None, None
|
| 83 |
+
parsed = _parse_json_object(raw)
|
| 84 |
+
return parsed, provider
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _fallback_chain(
|
| 88 |
+
system: str,
|
| 89 |
+
user: str,
|
| 90 |
+
history: List[Message],
|
| 91 |
+
max_tokens: int,
|
| 92 |
+
temperature: float,
|
| 93 |
+
skip: str,
|
| 94 |
+
) -> Tuple[Optional[str], Optional[str]]:
|
| 95 |
+
order = [p for p in ("gemini", "anthropic", "openai") if p != skip]
|
| 96 |
+
for provider in order:
|
| 97 |
+
if provider == "gemini" and not settings.gemini_api_key:
|
| 98 |
+
continue
|
| 99 |
+
if provider == "anthropic" and not settings.anthropic_api_key:
|
| 100 |
+
continue
|
| 101 |
+
if provider == "openai" and not settings.openai_api_key:
|
| 102 |
+
continue
|
| 103 |
+
fn = {"gemini": _gemini_text, "anthropic": _anthropic_text, "openai": _openai_text}[provider]
|
| 104 |
+
text = fn(system, user, history, max_tokens, temperature)
|
| 105 |
+
if text:
|
| 106 |
+
return text, provider
|
| 107 |
+
return None, None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _gemini_text(system: str, user: str, history: List[Message], max_tokens: int, temperature: float) -> Optional[str]:
|
| 111 |
+
if not settings.gemini_api_key:
|
| 112 |
+
return None
|
| 113 |
+
try:
|
| 114 |
+
import google.generativeai as genai
|
| 115 |
+
|
| 116 |
+
genai.configure(api_key=settings.gemini_api_key)
|
| 117 |
+
model = genai.GenerativeModel(
|
| 118 |
+
_model_for("gemini"),
|
| 119 |
+
system_instruction=system,
|
| 120 |
+
)
|
| 121 |
+
chat_history = []
|
| 122 |
+
for msg in history[-8:]:
|
| 123 |
+
role = msg.get("role")
|
| 124 |
+
if role not in ("user", "assistant"):
|
| 125 |
+
continue
|
| 126 |
+
chat_history.append({"role": "user" if role == "user" else "model", "parts": [msg["content"]]})
|
| 127 |
+
chat = model.start_chat(history=chat_history)
|
| 128 |
+
resp = chat.send_message(
|
| 129 |
+
user,
|
| 130 |
+
generation_config={"max_output_tokens": max_tokens, "temperature": temperature},
|
| 131 |
+
)
|
| 132 |
+
return (resp.text or "").strip() or None
|
| 133 |
+
except Exception as e:
|
| 134 |
+
print(f"[llm_service] gemini failed: {e}")
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _anthropic_text(system: str, user: str, history: List[Message], max_tokens: int, temperature: float) -> Optional[str]:
|
| 139 |
+
if not settings.anthropic_api_key:
|
| 140 |
+
return None
|
| 141 |
+
try:
|
| 142 |
+
import anthropic
|
| 143 |
+
|
| 144 |
+
client = anthropic.Anthropic(api_key=settings.anthropic_api_key)
|
| 145 |
+
msgs = [{"role": m["role"], "content": m["content"]} for m in history[-8:] if m.get("role") in ("user", "assistant")]
|
| 146 |
+
msgs.append({"role": "user", "content": user})
|
| 147 |
+
resp = client.messages.create(
|
| 148 |
+
model=_model_for("anthropic"),
|
| 149 |
+
max_tokens=max_tokens,
|
| 150 |
+
temperature=temperature,
|
| 151 |
+
system=system,
|
| 152 |
+
messages=msgs,
|
| 153 |
+
)
|
| 154 |
+
return "".join(getattr(b, "text", "") for b in resp.content).strip() or None
|
| 155 |
+
except Exception as e:
|
| 156 |
+
print(f"[llm_service] anthropic failed: {e}")
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _openai_text(system: str, user: str, history: List[Message], max_tokens: int, temperature: float) -> Optional[str]:
|
| 161 |
+
if not settings.openai_api_key:
|
| 162 |
+
return None
|
| 163 |
+
try:
|
| 164 |
+
from openai import OpenAI
|
| 165 |
+
|
| 166 |
+
client = OpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url or None)
|
| 167 |
+
msgs: List[Dict[str, str]] = [{"role": "system", "content": system}]
|
| 168 |
+
msgs += [{"role": m["role"], "content": m["content"]} for m in history[-8:] if m.get("role") in ("user", "assistant")]
|
| 169 |
+
msgs.append({"role": "user", "content": user})
|
| 170 |
+
resp = client.chat.completions.create(
|
| 171 |
+
model=_model_for("openai"),
|
| 172 |
+
messages=msgs,
|
| 173 |
+
max_tokens=max_tokens,
|
| 174 |
+
temperature=temperature,
|
| 175 |
+
)
|
| 176 |
+
return (resp.choices[0].message.content or "").strip() or None
|
| 177 |
+
except Exception as e:
|
| 178 |
+
print(f"[llm_service] openai failed: {e}")
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _parse_json_object(raw: str) -> Optional[Dict[str, Any]]:
|
| 183 |
+
text = raw.strip()
|
| 184 |
+
fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
| 185 |
+
if fence:
|
| 186 |
+
text = fence.group(1).strip()
|
| 187 |
+
try:
|
| 188 |
+
data = json.loads(text)
|
| 189 |
+
return data if isinstance(data, dict) else None
|
| 190 |
+
except json.JSONDecodeError:
|
| 191 |
+
start, end = text.find("{"), text.rfind("}")
|
| 192 |
+
if start >= 0 and end > start:
|
| 193 |
+
try:
|
| 194 |
+
data = json.loads(text[start : end + 1])
|
| 195 |
+
return data if isinstance(data, dict) else None
|
| 196 |
+
except json.JSONDecodeError:
|
| 197 |
+
return None
|
| 198 |
+
return None
|
app/services/ml/demand_forecaster.py
CHANGED
|
@@ -1,233 +1,233 @@
|
|
| 1 |
-
# app/services/ml/demand_forecaster.py
|
| 2 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
-
# Demand Forecasting — predicts monthly unit sales for next 3 months.
|
| 4 |
-
# Uses BSR → sales relationship (Jungle Scout / Helium 10 methodology)
|
| 5 |
-
# combined with seasonal adjustments and trend momentum.
|
| 6 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 7 |
-
|
| 8 |
-
import math
|
| 9 |
-
from datetime import datetime
|
| 10 |
-
from typing import Optional
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
# Seasonal multipliers by month (empirical Amazon sales data)
|
| 14 |
-
_SEASONALITY = {
|
| 15 |
-
1: 0.78, # January — post-holiday slump
|
| 16 |
-
2: 0.80, # February
|
| 17 |
-
3: 0.88, # March — spring pickup
|
| 18 |
-
4: 0.90, # April
|
| 19 |
-
5: 0.95, # May — Mother's Day boost
|
| 20 |
-
6: 0.88, # June
|
| 21 |
-
7: 0.85, # July — Prime Day offset
|
| 22 |
-
8: 0.87, # August — back to school
|
| 23 |
-
9: 0.90, # September
|
| 24 |
-
10: 1.05, # October — pre-holiday
|
| 25 |
-
11: 1.35, # November — Black Friday / Cyber Monday
|
| 26 |
-
12: 1.45, # December — peak holiday
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
# BSR to monthly units table (calibrated from Jungle Scout data)
|
| 30 |
-
# Format: (bsr_min, bsr_max, base_monthly_units)
|
| 31 |
-
_BSR_SALES_TABLE = [
|
| 32 |
-
(1, 100, 8500),
|
| 33 |
-
(100, 500, 4500),
|
| 34 |
-
(500, 1_000, 2800),
|
| 35 |
-
(1_000, 3_000, 1500),
|
| 36 |
-
(3_000, 5_000, 800),
|
| 37 |
-
(5_000, 10_000, 400),
|
| 38 |
-
(10_000, 25_000, 180),
|
| 39 |
-
(25_000, 50_000, 85),
|
| 40 |
-
(50_000, 100_000, 40),
|
| 41 |
-
(100_000, 250_000, 18),
|
| 42 |
-
(250_000, 500_000, 8),
|
| 43 |
-
(500_000, 1_000_000, 3),
|
| 44 |
-
(1_000_000, float("inf"), 1),
|
| 45 |
-
]
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _bsr_to_units(bsr: int) -> int:
|
| 49 |
-
for low, high, units in _BSR_SALES_TABLE:
|
| 50 |
-
if low <= bsr < high:
|
| 51 |
-
# Interpolate within the bracket
|
| 52 |
-
fraction = (bsr - low) / (high - low) if high != float("inf") else 0
|
| 53 |
-
# Units decrease as BSR increases within bracket
|
| 54 |
-
next_units = _BSR_SALES_TABLE[_BSR_SALES_TABLE.index((low, high, units)) + 1][2] \
|
| 55 |
-
if high != float("inf") else 1
|
| 56 |
-
return max(1, int(units - fraction * (units - next_units)))
|
| 57 |
-
return 1
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def forecast_demand(
|
| 61 |
-
bsr: Optional[int],
|
| 62 |
-
category: str = "",
|
| 63 |
-
review_count: int = 0,
|
| 64 |
-
rating: float = 0.0,
|
| 65 |
-
price: float = 0.0,
|
| 66 |
-
history: Optional[list] = None,
|
| 67 |
-
) -> dict:
|
| 68 |
-
"""
|
| 69 |
-
Forecast monthly demand for the next 3 months.
|
| 70 |
-
|
| 71 |
-
Returns unit estimates, revenue estimates, trend direction,
|
| 72 |
-
and seasonal analysis.
|
| 73 |
-
"""
|
| 74 |
-
if not bsr or bsr <= 0:
|
| 75 |
-
return _empty_forecast()
|
| 76 |
-
|
| 77 |
-
# ── Base sales from BSR ──────────────────────────────────────────────────
|
| 78 |
-
base_units = _bsr_to_units(bsr)
|
| 79 |
-
|
| 80 |
-
# ── Trend from BSR history ───────────────────────────────────────────────
|
| 81 |
-
trend_multiplier = 1.0
|
| 82 |
-
trend_description = "stable"
|
| 83 |
-
trend_confidence = 50
|
| 84 |
-
|
| 85 |
-
if history and len(history) >= 3:
|
| 86 |
-
bsr_values = [h.get("bsr") for h in history if h.get("bsr") and h["bsr"] > 0]
|
| 87 |
-
if len(bsr_values) >= 3:
|
| 88 |
-
# Lower BSR over time = improving sales = positive trend
|
| 89 |
-
recent = bsr_values[:3] # most recent 3
|
| 90 |
-
older = bsr_values[-3:] # oldest 3
|
| 91 |
-
avg_recent = sum(recent) / len(recent)
|
| 92 |
-
avg_older = sum(older) / len(older)
|
| 93 |
-
if avg_older > 0:
|
| 94 |
-
change = (avg_older - avg_recent) / avg_older # positive = BSR improving
|
| 95 |
-
trend_multiplier = 1.0 + change * 0.3 # up to ±30% adjustment
|
| 96 |
-
trend_multiplier = max(0.5, min(1.5, trend_multiplier))
|
| 97 |
-
trend_confidence = 75
|
| 98 |
-
if change > 0.1:
|
| 99 |
-
trend_description = "growing"
|
| 100 |
-
elif change < -0.1:
|
| 101 |
-
trend_description = "declining"
|
| 102 |
-
|
| 103 |
-
# ── Review momentum multiplier ───────────────────────────────────────────
|
| 104 |
-
# High review count relative to BSR = loyal buyers = stable demand
|
| 105 |
-
review_mult = 1.0
|
| 106 |
-
if review_count > 1000:
|
| 107 |
-
review_mult = 1.08
|
| 108 |
-
elif review_count > 500:
|
| 109 |
-
review_mult = 1.04
|
| 110 |
-
elif review_count < 10:
|
| 111 |
-
review_mult = 0.92 # low review count = product is new/uncertain
|
| 112 |
-
|
| 113 |
-
# Rating adjustment
|
| 114 |
-
if rating >= 4.5:
|
| 115 |
-
review_mult *= 1.03
|
| 116 |
-
elif rating < 3.5 and rating > 0:
|
| 117 |
-
review_mult *= 0.90 # poor ratings reduce repeat purchases
|
| 118 |
-
|
| 119 |
-
# ── Seasonal forecasting ─────────────────────────────────────────────────
|
| 120 |
-
now = datetime.now()
|
| 121 |
-
current_month = now.month
|
| 122 |
-
|
| 123 |
-
months = []
|
| 124 |
-
for i in range(1, 4):
|
| 125 |
-
month_num = ((current_month - 1 + i) % 12) + 1
|
| 126 |
-
season_mult = _SEASONALITY[month_num]
|
| 127 |
-
month_units = int(base_units * trend_multiplier * review_mult * season_mult)
|
| 128 |
-
month_units = max(1, month_units)
|
| 129 |
-
month_rev = round(month_units * price, 2) if price > 0 else None
|
| 130 |
-
|
| 131 |
-
months.append({
|
| 132 |
-
"month": _month_name(month_num),
|
| 133 |
-
"month_num": month_num,
|
| 134 |
-
"units": month_units,
|
| 135 |
-
"revenue": month_rev,
|
| 136 |
-
"seasonal_factor": round(season_mult, 2),
|
| 137 |
-
"is_peak": season_mult >= 1.2,
|
| 138 |
-
})
|
| 139 |
-
|
| 140 |
-
# ── Current month baseline ───────────────────────────────────────────────
|
| 141 |
-
current_season_mult = _SEASONALITY[current_month]
|
| 142 |
-
current_units = int(base_units * review_mult * current_season_mult)
|
| 143 |
-
|
| 144 |
-
# ── Annual projection ────────────────────────────────────────────────────
|
| 145 |
-
annual_units = int(base_units * review_mult * trend_multiplier * 12 *
|
| 146 |
-
(sum(_SEASONALITY.values()) / 12))
|
| 147 |
-
annual_rev = round(annual_units * price, 2) if price > 0 else None
|
| 148 |
-
|
| 149 |
-
# ── Peak season identification ───────────────────────────────────────────
|
| 150 |
-
peak_months = [m for m, s in _SEASONALITY.items() if s >= 1.1]
|
| 151 |
-
peak_names = [_month_name(m) for m in peak_months]
|
| 152 |
-
|
| 153 |
-
# ── Confidence ───────────────────────────────────────────────────────────
|
| 154 |
-
confidence = trend_confidence
|
| 155 |
-
if price > 0: confidence += 5
|
| 156 |
-
if review_count > 50: confidence += 8
|
| 157 |
-
confidence = min(90, confidence)
|
| 158 |
-
|
| 159 |
-
# ── Insight ──────────────────────────────────────────────────────────────
|
| 160 |
-
if trend_description == "growing":
|
| 161 |
-
insight = f"Demand is trending upward. BSR has been improving — consider stocking up before peak season."
|
| 162 |
-
elif trend_description == "declining":
|
| 163 |
-
insight = f"Demand is declining. Reduce inventory risk by ordering conservatively."
|
| 164 |
-
else:
|
| 165 |
-
insight = f"Demand is stable. Focus on peak months ({', '.join(peak_names[:3])}) for maximum revenue."
|
| 166 |
-
|
| 167 |
-
result = {
|
| 168 |
-
"current_monthly_units": current_units,
|
| 169 |
-
"base_bsr_units": base_units,
|
| 170 |
-
"trend": trend_description,
|
| 171 |
-
"trend_multiplier": round(trend_multiplier, 3),
|
| 172 |
-
"forecast": months,
|
| 173 |
-
"annual_projection": {
|
| 174 |
-
"units": annual_units,
|
| 175 |
-
"revenue": annual_rev,
|
| 176 |
-
},
|
| 177 |
-
"peak_months": peak_names,
|
| 178 |
-
"confidence": confidence,
|
| 179 |
-
"insight": insight,
|
| 180 |
-
"engine": "heuristic",
|
| 181 |
-
"model": "bsr-seasonal",
|
| 182 |
-
"model_inputs": {
|
| 183 |
-
"bsr": bsr,
|
| 184 |
-
"review_count": review_count,
|
| 185 |
-
"rating": rating,
|
| 186 |
-
"price": price,
|
| 187 |
-
},
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
try:
|
| 191 |
-
from app.services.ml.sklearn_engine import get_engine
|
| 192 |
-
engine = get_engine()
|
| 193 |
-
if engine:
|
| 194 |
-
ml = engine.predict_demand(bsr, current_units, rating, review_count, price)
|
| 195 |
-
ml_units = ml["next_month_units"]
|
| 196 |
-
scale = ml_units / max(current_units, 1)
|
| 197 |
-
result["trend"] = ml["trend"]
|
| 198 |
-
result["trend_multiplier"] = round(scale, 3)
|
| 199 |
-
for m in result["forecast"]:
|
| 200 |
-
m["units"] = max(1, int(m["units"] * scale))
|
| 201 |
-
if price > 0:
|
| 202 |
-
m["revenue"] = round(m["units"] * price, 2)
|
| 203 |
-
result["confidence"] = max(confidence, ml.get("confidence", 70))
|
| 204 |
-
result["engine"] = "sklearn+heuristic"
|
| 205 |
-
result["model"] = ml.get("model", "GradientBoostingRegressor")
|
| 206 |
-
result["model_version"] = ml.get("model_version")
|
| 207 |
-
result["insight"] = (
|
| 208 |
-
f"ML demand model forecasts ~{ml_units:,} units next month ({ml['trend']}). "
|
| 209 |
-
+ insight
|
| 210 |
-
)
|
| 211 |
-
except Exception as e:
|
| 212 |
-
print(f"[demand_forecaster] sklearn blend failed: {e}")
|
| 213 |
-
|
| 214 |
-
return result
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
def _month_name(n: int) -> str:
|
| 218 |
-
return ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][n-1]
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def _empty_forecast():
|
| 222 |
-
return {
|
| 223 |
-
"current_monthly_units": 0,
|
| 224 |
-
"base_bsr_units": 0,
|
| 225 |
-
"trend": "unknown",
|
| 226 |
-
"trend_multiplier": 1.0,
|
| 227 |
-
"forecast": [],
|
| 228 |
-
"annual_projection": {"units": 0, "revenue": None},
|
| 229 |
-
"peak_months": ["November", "December"],
|
| 230 |
-
"confidence": 20,
|
| 231 |
-
"insight": "BSR data required for demand forecasting.",
|
| 232 |
-
"model_inputs": {"bsr": None, "review_count": 0, "rating": 0, "price": 0},
|
| 233 |
}
|
|
|
|
| 1 |
+
# app/services/ml/demand_forecaster.py
|
| 2 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# Demand Forecasting — predicts monthly unit sales for next 3 months.
|
| 4 |
+
# Uses BSR → sales relationship (Jungle Scout / Helium 10 methodology)
|
| 5 |
+
# combined with seasonal adjustments and trend momentum.
|
| 6 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# Seasonal multipliers by month (empirical Amazon sales data)
|
| 14 |
+
_SEASONALITY = {
|
| 15 |
+
1: 0.78, # January — post-holiday slump
|
| 16 |
+
2: 0.80, # February
|
| 17 |
+
3: 0.88, # March — spring pickup
|
| 18 |
+
4: 0.90, # April
|
| 19 |
+
5: 0.95, # May — Mother's Day boost
|
| 20 |
+
6: 0.88, # June
|
| 21 |
+
7: 0.85, # July — Prime Day offset
|
| 22 |
+
8: 0.87, # August — back to school
|
| 23 |
+
9: 0.90, # September
|
| 24 |
+
10: 1.05, # October — pre-holiday
|
| 25 |
+
11: 1.35, # November — Black Friday / Cyber Monday
|
| 26 |
+
12: 1.45, # December — peak holiday
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
# BSR to monthly units table (calibrated from Jungle Scout data)
|
| 30 |
+
# Format: (bsr_min, bsr_max, base_monthly_units)
|
| 31 |
+
_BSR_SALES_TABLE = [
|
| 32 |
+
(1, 100, 8500),
|
| 33 |
+
(100, 500, 4500),
|
| 34 |
+
(500, 1_000, 2800),
|
| 35 |
+
(1_000, 3_000, 1500),
|
| 36 |
+
(3_000, 5_000, 800),
|
| 37 |
+
(5_000, 10_000, 400),
|
| 38 |
+
(10_000, 25_000, 180),
|
| 39 |
+
(25_000, 50_000, 85),
|
| 40 |
+
(50_000, 100_000, 40),
|
| 41 |
+
(100_000, 250_000, 18),
|
| 42 |
+
(250_000, 500_000, 8),
|
| 43 |
+
(500_000, 1_000_000, 3),
|
| 44 |
+
(1_000_000, float("inf"), 1),
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _bsr_to_units(bsr: int) -> int:
|
| 49 |
+
for low, high, units in _BSR_SALES_TABLE:
|
| 50 |
+
if low <= bsr < high:
|
| 51 |
+
# Interpolate within the bracket
|
| 52 |
+
fraction = (bsr - low) / (high - low) if high != float("inf") else 0
|
| 53 |
+
# Units decrease as BSR increases within bracket
|
| 54 |
+
next_units = _BSR_SALES_TABLE[_BSR_SALES_TABLE.index((low, high, units)) + 1][2] \
|
| 55 |
+
if high != float("inf") else 1
|
| 56 |
+
return max(1, int(units - fraction * (units - next_units)))
|
| 57 |
+
return 1
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def forecast_demand(
|
| 61 |
+
bsr: Optional[int],
|
| 62 |
+
category: str = "",
|
| 63 |
+
review_count: int = 0,
|
| 64 |
+
rating: float = 0.0,
|
| 65 |
+
price: float = 0.0,
|
| 66 |
+
history: Optional[list] = None,
|
| 67 |
+
) -> dict:
|
| 68 |
+
"""
|
| 69 |
+
Forecast monthly demand for the next 3 months.
|
| 70 |
+
|
| 71 |
+
Returns unit estimates, revenue estimates, trend direction,
|
| 72 |
+
and seasonal analysis.
|
| 73 |
+
"""
|
| 74 |
+
if not bsr or bsr <= 0:
|
| 75 |
+
return _empty_forecast()
|
| 76 |
+
|
| 77 |
+
# ── Base sales from BSR ──────────────────────────────────────────────────
|
| 78 |
+
base_units = _bsr_to_units(bsr)
|
| 79 |
+
|
| 80 |
+
# ── Trend from BSR history ───────────────────────────────────────────────
|
| 81 |
+
trend_multiplier = 1.0
|
| 82 |
+
trend_description = "stable"
|
| 83 |
+
trend_confidence = 50
|
| 84 |
+
|
| 85 |
+
if history and len(history) >= 3:
|
| 86 |
+
bsr_values = [h.get("bsr") for h in history if h.get("bsr") and h["bsr"] > 0]
|
| 87 |
+
if len(bsr_values) >= 3:
|
| 88 |
+
# Lower BSR over time = improving sales = positive trend
|
| 89 |
+
recent = bsr_values[:3] # most recent 3
|
| 90 |
+
older = bsr_values[-3:] # oldest 3
|
| 91 |
+
avg_recent = sum(recent) / len(recent)
|
| 92 |
+
avg_older = sum(older) / len(older)
|
| 93 |
+
if avg_older > 0:
|
| 94 |
+
change = (avg_older - avg_recent) / avg_older # positive = BSR improving
|
| 95 |
+
trend_multiplier = 1.0 + change * 0.3 # up to ±30% adjustment
|
| 96 |
+
trend_multiplier = max(0.5, min(1.5, trend_multiplier))
|
| 97 |
+
trend_confidence = 75
|
| 98 |
+
if change > 0.1:
|
| 99 |
+
trend_description = "growing"
|
| 100 |
+
elif change < -0.1:
|
| 101 |
+
trend_description = "declining"
|
| 102 |
+
|
| 103 |
+
# ── Review momentum multiplier ───────────────────────────────────────────
|
| 104 |
+
# High review count relative to BSR = loyal buyers = stable demand
|
| 105 |
+
review_mult = 1.0
|
| 106 |
+
if review_count > 1000:
|
| 107 |
+
review_mult = 1.08
|
| 108 |
+
elif review_count > 500:
|
| 109 |
+
review_mult = 1.04
|
| 110 |
+
elif review_count < 10:
|
| 111 |
+
review_mult = 0.92 # low review count = product is new/uncertain
|
| 112 |
+
|
| 113 |
+
# Rating adjustment
|
| 114 |
+
if rating >= 4.5:
|
| 115 |
+
review_mult *= 1.03
|
| 116 |
+
elif rating < 3.5 and rating > 0:
|
| 117 |
+
review_mult *= 0.90 # poor ratings reduce repeat purchases
|
| 118 |
+
|
| 119 |
+
# ── Seasonal forecasting ─────────────────────────────────────────────────
|
| 120 |
+
now = datetime.now()
|
| 121 |
+
current_month = now.month
|
| 122 |
+
|
| 123 |
+
months = []
|
| 124 |
+
for i in range(1, 4):
|
| 125 |
+
month_num = ((current_month - 1 + i) % 12) + 1
|
| 126 |
+
season_mult = _SEASONALITY[month_num]
|
| 127 |
+
month_units = int(base_units * trend_multiplier * review_mult * season_mult)
|
| 128 |
+
month_units = max(1, month_units)
|
| 129 |
+
month_rev = round(month_units * price, 2) if price > 0 else None
|
| 130 |
+
|
| 131 |
+
months.append({
|
| 132 |
+
"month": _month_name(month_num),
|
| 133 |
+
"month_num": month_num,
|
| 134 |
+
"units": month_units,
|
| 135 |
+
"revenue": month_rev,
|
| 136 |
+
"seasonal_factor": round(season_mult, 2),
|
| 137 |
+
"is_peak": season_mult >= 1.2,
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
# ── Current month baseline ───────────────────────────────────────────────
|
| 141 |
+
current_season_mult = _SEASONALITY[current_month]
|
| 142 |
+
current_units = int(base_units * review_mult * current_season_mult)
|
| 143 |
+
|
| 144 |
+
# ── Annual projection ────────────────────────────────────────────────────
|
| 145 |
+
annual_units = int(base_units * review_mult * trend_multiplier * 12 *
|
| 146 |
+
(sum(_SEASONALITY.values()) / 12))
|
| 147 |
+
annual_rev = round(annual_units * price, 2) if price > 0 else None
|
| 148 |
+
|
| 149 |
+
# ── Peak season identification ───────────────────────────────────────────
|
| 150 |
+
peak_months = [m for m, s in _SEASONALITY.items() if s >= 1.1]
|
| 151 |
+
peak_names = [_month_name(m) for m in peak_months]
|
| 152 |
+
|
| 153 |
+
# ── Confidence ───────────────────────────────────────────────────────────
|
| 154 |
+
confidence = trend_confidence
|
| 155 |
+
if price > 0: confidence += 5
|
| 156 |
+
if review_count > 50: confidence += 8
|
| 157 |
+
confidence = min(90, confidence)
|
| 158 |
+
|
| 159 |
+
# ── Insight ──────────────────────────────────────────────────────────────
|
| 160 |
+
if trend_description == "growing":
|
| 161 |
+
insight = f"Demand is trending upward. BSR has been improving — consider stocking up before peak season."
|
| 162 |
+
elif trend_description == "declining":
|
| 163 |
+
insight = f"Demand is declining. Reduce inventory risk by ordering conservatively."
|
| 164 |
+
else:
|
| 165 |
+
insight = f"Demand is stable. Focus on peak months ({', '.join(peak_names[:3])}) for maximum revenue."
|
| 166 |
+
|
| 167 |
+
result = {
|
| 168 |
+
"current_monthly_units": current_units,
|
| 169 |
+
"base_bsr_units": base_units,
|
| 170 |
+
"trend": trend_description,
|
| 171 |
+
"trend_multiplier": round(trend_multiplier, 3),
|
| 172 |
+
"forecast": months,
|
| 173 |
+
"annual_projection": {
|
| 174 |
+
"units": annual_units,
|
| 175 |
+
"revenue": annual_rev,
|
| 176 |
+
},
|
| 177 |
+
"peak_months": peak_names,
|
| 178 |
+
"confidence": confidence,
|
| 179 |
+
"insight": insight,
|
| 180 |
+
"engine": "heuristic",
|
| 181 |
+
"model": "bsr-seasonal",
|
| 182 |
+
"model_inputs": {
|
| 183 |
+
"bsr": bsr,
|
| 184 |
+
"review_count": review_count,
|
| 185 |
+
"rating": rating,
|
| 186 |
+
"price": price,
|
| 187 |
+
},
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
from app.services.ml.sklearn_engine import get_engine
|
| 192 |
+
engine = get_engine()
|
| 193 |
+
if engine:
|
| 194 |
+
ml = engine.predict_demand(bsr, current_units, rating, review_count, price)
|
| 195 |
+
ml_units = ml["next_month_units"]
|
| 196 |
+
scale = ml_units / max(current_units, 1)
|
| 197 |
+
result["trend"] = ml["trend"]
|
| 198 |
+
result["trend_multiplier"] = round(scale, 3)
|
| 199 |
+
for m in result["forecast"]:
|
| 200 |
+
m["units"] = max(1, int(m["units"] * scale))
|
| 201 |
+
if price > 0:
|
| 202 |
+
m["revenue"] = round(m["units"] * price, 2)
|
| 203 |
+
result["confidence"] = max(confidence, ml.get("confidence", 70))
|
| 204 |
+
result["engine"] = "sklearn+heuristic"
|
| 205 |
+
result["model"] = ml.get("model", "GradientBoostingRegressor")
|
| 206 |
+
result["model_version"] = ml.get("model_version")
|
| 207 |
+
result["insight"] = (
|
| 208 |
+
f"ML demand model forecasts ~{ml_units:,} units next month ({ml['trend']}). "
|
| 209 |
+
+ insight
|
| 210 |
+
)
|
| 211 |
+
except Exception as e:
|
| 212 |
+
print(f"[demand_forecaster] sklearn blend failed: {e}")
|
| 213 |
+
|
| 214 |
+
return result
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _month_name(n: int) -> str:
|
| 218 |
+
return ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][n-1]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _empty_forecast():
|
| 222 |
+
return {
|
| 223 |
+
"current_monthly_units": 0,
|
| 224 |
+
"base_bsr_units": 0,
|
| 225 |
+
"trend": "unknown",
|
| 226 |
+
"trend_multiplier": 1.0,
|
| 227 |
+
"forecast": [],
|
| 228 |
+
"annual_projection": {"units": 0, "revenue": None},
|
| 229 |
+
"peak_months": ["November", "December"],
|
| 230 |
+
"confidence": 20,
|
| 231 |
+
"insight": "BSR data required for demand forecasting.",
|
| 232 |
+
"model_inputs": {"bsr": None, "review_count": 0, "rating": 0, "price": 0},
|
| 233 |
}
|
app/services/ml/fake_review_detector.py
CHANGED
|
@@ -1,287 +1,287 @@
|
|
| 1 |
-
# app/services/ml/fake_review_detector.py
|
| 2 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
-
# Fake Review Detection using statistical pattern analysis.
|
| 4 |
-
# No external ML library needed — uses pure math on review patterns.
|
| 5 |
-
#
|
| 6 |
-
# Academic basis: Ott et al. (2011) "Finding deceptive opinion spam"
|
| 7 |
-
# Signals used:
|
| 8 |
-
# 1. Rating distribution — real products follow bell curve; fake ones skew 5★
|
| 9 |
-
# 2. Review velocity — sudden spike = bulk fake reviews
|
| 10 |
-
# 3. Verified purchase ratio — low verified% is a red flag
|
| 11 |
-
# 4. Rating vs text sentiment gap — 5★ but mediocre language
|
| 12 |
-
# 5. Review count vs BSR correlation — mismatch signals manipulation
|
| 13 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 14 |
-
|
| 15 |
-
import math
|
| 16 |
-
from typing import Optional
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def detect_fake_reviews(
|
| 20 |
-
review_count: int,
|
| 21 |
-
rating: float,
|
| 22 |
-
bsr: Optional[int],
|
| 23 |
-
monthly_sales: Optional[int],
|
| 24 |
-
seller_count: int = 1,
|
| 25 |
-
is_fba: bool = False,
|
| 26 |
-
) -> dict:
|
| 27 |
-
"""
|
| 28 |
-
Analyse product signals and return fake review risk assessment.
|
| 29 |
-
|
| 30 |
-
Returns a dict with:
|
| 31 |
-
risk_score : 0–100 (higher = more suspicious)
|
| 32 |
-
risk_level : "low" | "medium" | "high" | "very_high"
|
| 33 |
-
confidence : 0–100 (how confident we are in the assessment)
|
| 34 |
-
signals : list of detected signal dicts
|
| 35 |
-
verdict : human-readable summary string
|
| 36 |
-
trust_score : 0–100 (inverse of risk — for display)
|
| 37 |
-
recommendation : action string
|
| 38 |
-
"""
|
| 39 |
-
signals = []
|
| 40 |
-
risk_points = 0
|
| 41 |
-
max_points = 0
|
| 42 |
-
|
| 43 |
-
# ── Signal 1: Rating distribution suspicion ──────────────────────────────
|
| 44 |
-
# Products with perfect or near-perfect ratings are suspicious,
|
| 45 |
-
# especially with many reviews (hard to maintain naturally).
|
| 46 |
-
max_points += 25
|
| 47 |
-
if rating >= 4.9 and review_count > 50:
|
| 48 |
-
pts = 25
|
| 49 |
-
signals.append({
|
| 50 |
-
"name": "Near-perfect rating",
|
| 51 |
-
"description": f"{rating}★ across {review_count:,} reviews is statistically unusual for real products",
|
| 52 |
-
"severity": "high",
|
| 53 |
-
"icon": "star",
|
| 54 |
-
})
|
| 55 |
-
elif rating >= 4.8 and review_count > 200:
|
| 56 |
-
pts = 18
|
| 57 |
-
signals.append({
|
| 58 |
-
"name": "Suspiciously high rating",
|
| 59 |
-
"description": f"{rating}★ across {review_count:,} reviews — genuine products rarely sustain this",
|
| 60 |
-
"severity": "medium",
|
| 61 |
-
"icon": "star",
|
| 62 |
-
})
|
| 63 |
-
elif rating < 3.5 and review_count > 100:
|
| 64 |
-
# Very low rating with many reviews can also mean bought reviews that backfired
|
| 65 |
-
pts = 10
|
| 66 |
-
signals.append({
|
| 67 |
-
"name": "Low rating with high review count",
|
| 68 |
-
"description": "Many reviews but poor rating — possible failed review manipulation",
|
| 69 |
-
"severity": "low",
|
| 70 |
-
"icon": "alert",
|
| 71 |
-
})
|
| 72 |
-
else:
|
| 73 |
-
pts = 0
|
| 74 |
-
risk_points += pts
|
| 75 |
-
|
| 76 |
-
# ── Signal 2: Review count vs BSR mismatch ────────────────────────────────
|
| 77 |
-
# If BSR is high (poor seller) but review count is large, something is off.
|
| 78 |
-
# A product with BSR 500,000 shouldn't have 5,000 reviews.
|
| 79 |
-
max_points += 25
|
| 80 |
-
if bsr and bsr > 0:
|
| 81 |
-
# Expected reviews for this BSR using empirical Amazon data:
|
| 82 |
-
# BSR 1-1000 → ~10,000+ reviews normal
|
| 83 |
-
# BSR 1000-10000 → ~500-5000 reviews normal
|
| 84 |
-
# BSR 10000-100000 → ~50-500 reviews normal
|
| 85 |
-
# BSR 100000+ → <50 reviews normal
|
| 86 |
-
if bsr < 1_000:
|
| 87 |
-
expected_max = 50_000
|
| 88 |
-
elif bsr < 10_000:
|
| 89 |
-
expected_max = 5_000
|
| 90 |
-
elif bsr < 100_000:
|
| 91 |
-
expected_max = 500
|
| 92 |
-
elif bsr < 500_000:
|
| 93 |
-
expected_max = 100
|
| 94 |
-
else:
|
| 95 |
-
expected_max = 30
|
| 96 |
-
|
| 97 |
-
ratio = review_count / expected_max if expected_max > 0 else 1.0
|
| 98 |
-
|
| 99 |
-
if ratio > 5.0:
|
| 100 |
-
pts = 25
|
| 101 |
-
signals.append({
|
| 102 |
-
"name": "Review count vs sales rank mismatch",
|
| 103 |
-
"description": f"BSR #{bsr:,} implies low sales but {review_count:,} reviews — {ratio:.0f}× more than expected",
|
| 104 |
-
"severity": "high",
|
| 105 |
-
"icon": "chart-bar",
|
| 106 |
-
})
|
| 107 |
-
elif ratio > 2.5:
|
| 108 |
-
pts = 15
|
| 109 |
-
signals.append({
|
| 110 |
-
"name": "Elevated review-to-rank ratio",
|
| 111 |
-
"description": f"Review count is {ratio:.0f}× higher than expected for BSR #{bsr:,}",
|
| 112 |
-
"severity": "medium",
|
| 113 |
-
"icon": "chart-bar",
|
| 114 |
-
})
|
| 115 |
-
else:
|
| 116 |
-
pts = 0
|
| 117 |
-
else:
|
| 118 |
-
pts = 0
|
| 119 |
-
risk_points += pts
|
| 120 |
-
|
| 121 |
-
# ── Signal 3: Review-to-sales conversion anomaly ─────────────────────────
|
| 122 |
-
# Normally ~2-5% of buyers leave reviews on Amazon.
|
| 123 |
-
# If reviews >> estimated buyers, manipulation is likely.
|
| 124 |
-
max_points += 20
|
| 125 |
-
if monthly_sales and monthly_sales > 0:
|
| 126 |
-
# Estimate total lifetime sales (rough: product has been live ~12 months avg)
|
| 127 |
-
estimated_total_buyers = monthly_sales * 12
|
| 128 |
-
natural_review_rate = 0.04 # 4% of buyers review
|
| 129 |
-
expected_reviews = estimated_total_buyers * natural_review_rate
|
| 130 |
-
if expected_reviews > 0:
|
| 131 |
-
review_ratio = review_count / expected_reviews
|
| 132 |
-
if review_ratio > 4.0:
|
| 133 |
-
pts = 20
|
| 134 |
-
signals.append({
|
| 135 |
-
"name": "Abnormal review conversion rate",
|
| 136 |
-
"description": f"Review rate is {review_ratio:.0f}× above Amazon's natural 4% — suggests purchased reviews",
|
| 137 |
-
"severity": "high",
|
| 138 |
-
"icon": "users",
|
| 139 |
-
})
|
| 140 |
-
elif review_ratio > 2.0:
|
| 141 |
-
pts = 10
|
| 142 |
-
signals.append({
|
| 143 |
-
"name": "Above-average review rate",
|
| 144 |
-
"description": f"Review count is {review_ratio:.0f}× what organic sales would produce",
|
| 145 |
-
"severity": "medium",
|
| 146 |
-
"icon": "users",
|
| 147 |
-
})
|
| 148 |
-
else:
|
| 149 |
-
pts = 0
|
| 150 |
-
else:
|
| 151 |
-
pts = 0
|
| 152 |
-
else:
|
| 153 |
-
pts = 0
|
| 154 |
-
risk_points += pts
|
| 155 |
-
|
| 156 |
-
# ── Signal 4: Seller pattern risk ────────────────────────────────────────
|
| 157 |
-
# Single seller + FBA + suspiciously high rating = common fake review setup
|
| 158 |
-
max_points += 15
|
| 159 |
-
if seller_count == 1 and rating >= 4.7 and review_count > 100:
|
| 160 |
-
pts = 15
|
| 161 |
-
signals.append({
|
| 162 |
-
"name": "Monopoly seller with high rating",
|
| 163 |
-
"description": "Single seller controls buy box and maintains very high rating — common in review farm operations",
|
| 164 |
-
"severity": "medium",
|
| 165 |
-
"icon": "building-store",
|
| 166 |
-
})
|
| 167 |
-
elif seller_count <= 2 and rating >= 4.8:
|
| 168 |
-
pts = 8
|
| 169 |
-
signals.append({
|
| 170 |
-
"name": "Limited competition with high rating",
|
| 171 |
-
"description": "Very few sellers with near-perfect rating — lower accountability for review authenticity",
|
| 172 |
-
"severity": "low",
|
| 173 |
-
"icon": "building-store",
|
| 174 |
-
})
|
| 175 |
-
else:
|
| 176 |
-
pts = 0
|
| 177 |
-
risk_points += pts
|
| 178 |
-
|
| 179 |
-
# ── Signal 5: Review count cluster check ─────────────────────────────────
|
| 180 |
-
# Certain review counts are suspicious (round numbers like exactly 1000, 2000
|
| 181 |
-
# or very specific patterns from bulk review services)
|
| 182 |
-
max_points += 15
|
| 183 |
-
suspicious_counts = [100, 200, 500, 1000, 1500, 2000, 2500, 3000, 5000]
|
| 184 |
-
if any(abs(review_count - sc) <= 2 for sc in suspicious_counts) and review_count > 50:
|
| 185 |
-
pts = 10
|
| 186 |
-
signals.append({
|
| 187 |
-
"name": "Suspiciously round review count",
|
| 188 |
-
"description": f"{review_count} reviews — round numbers sometimes indicate bulk review purchases that stopped at a target",
|
| 189 |
-
"severity": "low",
|
| 190 |
-
"icon": "hash",
|
| 191 |
-
})
|
| 192 |
-
else:
|
| 193 |
-
pts = 0
|
| 194 |
-
risk_points += pts
|
| 195 |
-
|
| 196 |
-
# ── Positive trust signals (reduce risk) ─────────────────────────────────
|
| 197 |
-
trust_signals = []
|
| 198 |
-
|
| 199 |
-
if rating >= 3.5 and rating <= 4.4 and review_count > 20:
|
| 200 |
-
trust_signals.append("Realistic rating range — neither perfect nor manipulated downward")
|
| 201 |
-
risk_points = max(0, risk_points - 5)
|
| 202 |
-
|
| 203 |
-
if is_fba and seller_count > 3:
|
| 204 |
-
trust_signals.append("Multiple FBA sellers — competitive market reduces manipulation incentive")
|
| 205 |
-
risk_points = max(0, risk_points - 5)
|
| 206 |
-
|
| 207 |
-
if bsr and bsr < 5000 and review_count > 100:
|
| 208 |
-
trust_signals.append("Strong BSR supports high review count — organic success likely")
|
| 209 |
-
risk_points = max(0, risk_points - 8)
|
| 210 |
-
|
| 211 |
-
# ── Calculate final scores ────────────────────────────────────────────────
|
| 212 |
-
risk_score = min(100, int((risk_points / max_points) * 100)) if max_points > 0 else 0
|
| 213 |
-
|
| 214 |
-
# Confidence: higher when we have more data points
|
| 215 |
-
data_richness = sum([
|
| 216 |
-
1 if review_count > 0 else 0,
|
| 217 |
-
1 if rating > 0 else 0,
|
| 218 |
-
1 if bsr and bsr > 0 else 0,
|
| 219 |
-
1 if monthly_sales and monthly_sales > 0 else 0,
|
| 220 |
-
])
|
| 221 |
-
confidence = int((data_richness / 4) * 85) + 10 # 10–95%
|
| 222 |
-
|
| 223 |
-
# Risk level thresholds
|
| 224 |
-
if risk_score >= 65:
|
| 225 |
-
risk_level = "very_high"
|
| 226 |
-
verdict = "High probability of fake reviews — exercise extreme caution before investing"
|
| 227 |
-
recommendation = "Do not source this product. The review count and rating pattern strongly suggests manufactured social proof."
|
| 228 |
-
elif risk_score >= 45:
|
| 229 |
-
risk_level = "high"
|
| 230 |
-
verdict = "Several suspicious patterns detected — reviews may be partially manipulated"
|
| 231 |
-
recommendation = "Investigate further. Check review dates for sudden spikes and read 1★ and 3★ reviews carefully."
|
| 232 |
-
elif risk_score >= 25:
|
| 233 |
-
risk_level = "medium"
|
| 234 |
-
verdict = "Some anomalies present — reviews are likely mostly genuine with possible padding"
|
| 235 |
-
recommendation = "Proceed with caution. Read recent reviews and check for duplicate review text."
|
| 236 |
-
else:
|
| 237 |
-
risk_level = "low"
|
| 238 |
-
verdict = "Review profile appears organic — no major red flags detected"
|
| 239 |
-
recommendation = "Reviews appear trustworthy. Standard due diligence still recommended."
|
| 240 |
-
|
| 241 |
-
trust_score = 100 - risk_score
|
| 242 |
-
|
| 243 |
-
result = {
|
| 244 |
-
"risk_score": risk_score,
|
| 245 |
-
"risk_level": risk_level,
|
| 246 |
-
"trust_score": trust_score,
|
| 247 |
-
"confidence": confidence,
|
| 248 |
-
"verdict": verdict,
|
| 249 |
-
"recommendation": recommendation,
|
| 250 |
-
"signals": signals,
|
| 251 |
-
"trust_signals": trust_signals,
|
| 252 |
-
"engine": "heuristic",
|
| 253 |
-
"model": "statistical-signals",
|
| 254 |
-
"stats": {
|
| 255 |
-
"review_count": review_count,
|
| 256 |
-
"rating": rating,
|
| 257 |
-
"bsr": bsr,
|
| 258 |
-
"monthly_sales": monthly_sales,
|
| 259 |
-
},
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
try:
|
| 263 |
-
from app.services.ml.sklearn_engine import get_engine
|
| 264 |
-
engine = get_engine()
|
| 265 |
-
if engine:
|
| 266 |
-
ml = engine.predict_fake_review_risk(
|
| 267 |
-
review_count, rating, bsr, monthly_sales, seller_count
|
| 268 |
-
)
|
| 269 |
-
blended = int(min(100, max(0, ml["risk_score"] * 0.65 + risk_score * 0.35)))
|
| 270 |
-
result["risk_score"] = blended
|
| 271 |
-
result["trust_score"] = 100 - blended
|
| 272 |
-
result["confidence"] = max(confidence, ml.get("confidence", 70))
|
| 273 |
-
result["engine"] = "sklearn+heuristic"
|
| 274 |
-
result["model"] = ml.get("model", "RandomForestClassifier")
|
| 275 |
-
result["model_version"] = ml.get("model_version")
|
| 276 |
-
if blended >= 65:
|
| 277 |
-
result["risk_level"] = "very_high"
|
| 278 |
-
elif blended >= 45:
|
| 279 |
-
result["risk_level"] = "high"
|
| 280 |
-
elif blended >= 25:
|
| 281 |
-
result["risk_level"] = "medium"
|
| 282 |
-
else:
|
| 283 |
-
result["risk_level"] = "low"
|
| 284 |
-
except Exception as e:
|
| 285 |
-
print(f"[fake_review_detector] sklearn blend failed: {e}")
|
| 286 |
-
|
| 287 |
return result
|
|
|
|
| 1 |
+
# app/services/ml/fake_review_detector.py
|
| 2 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# Fake Review Detection using statistical pattern analysis.
|
| 4 |
+
# No external ML library needed — uses pure math on review patterns.
|
| 5 |
+
#
|
| 6 |
+
# Academic basis: Ott et al. (2011) "Finding deceptive opinion spam"
|
| 7 |
+
# Signals used:
|
| 8 |
+
# 1. Rating distribution — real products follow bell curve; fake ones skew 5★
|
| 9 |
+
# 2. Review velocity — sudden spike = bulk fake reviews
|
| 10 |
+
# 3. Verified purchase ratio — low verified% is a red flag
|
| 11 |
+
# 4. Rating vs text sentiment gap — 5★ but mediocre language
|
| 12 |
+
# 5. Review count vs BSR correlation — mismatch signals manipulation
|
| 13 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def detect_fake_reviews(
|
| 20 |
+
review_count: int,
|
| 21 |
+
rating: float,
|
| 22 |
+
bsr: Optional[int],
|
| 23 |
+
monthly_sales: Optional[int],
|
| 24 |
+
seller_count: int = 1,
|
| 25 |
+
is_fba: bool = False,
|
| 26 |
+
) -> dict:
|
| 27 |
+
"""
|
| 28 |
+
Analyse product signals and return fake review risk assessment.
|
| 29 |
+
|
| 30 |
+
Returns a dict with:
|
| 31 |
+
risk_score : 0–100 (higher = more suspicious)
|
| 32 |
+
risk_level : "low" | "medium" | "high" | "very_high"
|
| 33 |
+
confidence : 0–100 (how confident we are in the assessment)
|
| 34 |
+
signals : list of detected signal dicts
|
| 35 |
+
verdict : human-readable summary string
|
| 36 |
+
trust_score : 0–100 (inverse of risk — for display)
|
| 37 |
+
recommendation : action string
|
| 38 |
+
"""
|
| 39 |
+
signals = []
|
| 40 |
+
risk_points = 0
|
| 41 |
+
max_points = 0
|
| 42 |
+
|
| 43 |
+
# ── Signal 1: Rating distribution suspicion ──────────────────────────────
|
| 44 |
+
# Products with perfect or near-perfect ratings are suspicious,
|
| 45 |
+
# especially with many reviews (hard to maintain naturally).
|
| 46 |
+
max_points += 25
|
| 47 |
+
if rating >= 4.9 and review_count > 50:
|
| 48 |
+
pts = 25
|
| 49 |
+
signals.append({
|
| 50 |
+
"name": "Near-perfect rating",
|
| 51 |
+
"description": f"{rating}★ across {review_count:,} reviews is statistically unusual for real products",
|
| 52 |
+
"severity": "high",
|
| 53 |
+
"icon": "star",
|
| 54 |
+
})
|
| 55 |
+
elif rating >= 4.8 and review_count > 200:
|
| 56 |
+
pts = 18
|
| 57 |
+
signals.append({
|
| 58 |
+
"name": "Suspiciously high rating",
|
| 59 |
+
"description": f"{rating}★ across {review_count:,} reviews — genuine products rarely sustain this",
|
| 60 |
+
"severity": "medium",
|
| 61 |
+
"icon": "star",
|
| 62 |
+
})
|
| 63 |
+
elif rating < 3.5 and review_count > 100:
|
| 64 |
+
# Very low rating with many reviews can also mean bought reviews that backfired
|
| 65 |
+
pts = 10
|
| 66 |
+
signals.append({
|
| 67 |
+
"name": "Low rating with high review count",
|
| 68 |
+
"description": "Many reviews but poor rating — possible failed review manipulation",
|
| 69 |
+
"severity": "low",
|
| 70 |
+
"icon": "alert",
|
| 71 |
+
})
|
| 72 |
+
else:
|
| 73 |
+
pts = 0
|
| 74 |
+
risk_points += pts
|
| 75 |
+
|
| 76 |
+
# ── Signal 2: Review count vs BSR mismatch ────────────────────────────────
|
| 77 |
+
# If BSR is high (poor seller) but review count is large, something is off.
|
| 78 |
+
# A product with BSR 500,000 shouldn't have 5,000 reviews.
|
| 79 |
+
max_points += 25
|
| 80 |
+
if bsr and bsr > 0:
|
| 81 |
+
# Expected reviews for this BSR using empirical Amazon data:
|
| 82 |
+
# BSR 1-1000 → ~10,000+ reviews normal
|
| 83 |
+
# BSR 1000-10000 → ~500-5000 reviews normal
|
| 84 |
+
# BSR 10000-100000 → ~50-500 reviews normal
|
| 85 |
+
# BSR 100000+ → <50 reviews normal
|
| 86 |
+
if bsr < 1_000:
|
| 87 |
+
expected_max = 50_000
|
| 88 |
+
elif bsr < 10_000:
|
| 89 |
+
expected_max = 5_000
|
| 90 |
+
elif bsr < 100_000:
|
| 91 |
+
expected_max = 500
|
| 92 |
+
elif bsr < 500_000:
|
| 93 |
+
expected_max = 100
|
| 94 |
+
else:
|
| 95 |
+
expected_max = 30
|
| 96 |
+
|
| 97 |
+
ratio = review_count / expected_max if expected_max > 0 else 1.0
|
| 98 |
+
|
| 99 |
+
if ratio > 5.0:
|
| 100 |
+
pts = 25
|
| 101 |
+
signals.append({
|
| 102 |
+
"name": "Review count vs sales rank mismatch",
|
| 103 |
+
"description": f"BSR #{bsr:,} implies low sales but {review_count:,} reviews — {ratio:.0f}× more than expected",
|
| 104 |
+
"severity": "high",
|
| 105 |
+
"icon": "chart-bar",
|
| 106 |
+
})
|
| 107 |
+
elif ratio > 2.5:
|
| 108 |
+
pts = 15
|
| 109 |
+
signals.append({
|
| 110 |
+
"name": "Elevated review-to-rank ratio",
|
| 111 |
+
"description": f"Review count is {ratio:.0f}× higher than expected for BSR #{bsr:,}",
|
| 112 |
+
"severity": "medium",
|
| 113 |
+
"icon": "chart-bar",
|
| 114 |
+
})
|
| 115 |
+
else:
|
| 116 |
+
pts = 0
|
| 117 |
+
else:
|
| 118 |
+
pts = 0
|
| 119 |
+
risk_points += pts
|
| 120 |
+
|
| 121 |
+
# ── Signal 3: Review-to-sales conversion anomaly ─────────────────────────
|
| 122 |
+
# Normally ~2-5% of buyers leave reviews on Amazon.
|
| 123 |
+
# If reviews >> estimated buyers, manipulation is likely.
|
| 124 |
+
max_points += 20
|
| 125 |
+
if monthly_sales and monthly_sales > 0:
|
| 126 |
+
# Estimate total lifetime sales (rough: product has been live ~12 months avg)
|
| 127 |
+
estimated_total_buyers = monthly_sales * 12
|
| 128 |
+
natural_review_rate = 0.04 # 4% of buyers review
|
| 129 |
+
expected_reviews = estimated_total_buyers * natural_review_rate
|
| 130 |
+
if expected_reviews > 0:
|
| 131 |
+
review_ratio = review_count / expected_reviews
|
| 132 |
+
if review_ratio > 4.0:
|
| 133 |
+
pts = 20
|
| 134 |
+
signals.append({
|
| 135 |
+
"name": "Abnormal review conversion rate",
|
| 136 |
+
"description": f"Review rate is {review_ratio:.0f}× above Amazon's natural 4% — suggests purchased reviews",
|
| 137 |
+
"severity": "high",
|
| 138 |
+
"icon": "users",
|
| 139 |
+
})
|
| 140 |
+
elif review_ratio > 2.0:
|
| 141 |
+
pts = 10
|
| 142 |
+
signals.append({
|
| 143 |
+
"name": "Above-average review rate",
|
| 144 |
+
"description": f"Review count is {review_ratio:.0f}× what organic sales would produce",
|
| 145 |
+
"severity": "medium",
|
| 146 |
+
"icon": "users",
|
| 147 |
+
})
|
| 148 |
+
else:
|
| 149 |
+
pts = 0
|
| 150 |
+
else:
|
| 151 |
+
pts = 0
|
| 152 |
+
else:
|
| 153 |
+
pts = 0
|
| 154 |
+
risk_points += pts
|
| 155 |
+
|
| 156 |
+
# ── Signal 4: Seller pattern risk ────────────────────────────────────────
|
| 157 |
+
# Single seller + FBA + suspiciously high rating = common fake review setup
|
| 158 |
+
max_points += 15
|
| 159 |
+
if seller_count == 1 and rating >= 4.7 and review_count > 100:
|
| 160 |
+
pts = 15
|
| 161 |
+
signals.append({
|
| 162 |
+
"name": "Monopoly seller with high rating",
|
| 163 |
+
"description": "Single seller controls buy box and maintains very high rating — common in review farm operations",
|
| 164 |
+
"severity": "medium",
|
| 165 |
+
"icon": "building-store",
|
| 166 |
+
})
|
| 167 |
+
elif seller_count <= 2 and rating >= 4.8:
|
| 168 |
+
pts = 8
|
| 169 |
+
signals.append({
|
| 170 |
+
"name": "Limited competition with high rating",
|
| 171 |
+
"description": "Very few sellers with near-perfect rating — lower accountability for review authenticity",
|
| 172 |
+
"severity": "low",
|
| 173 |
+
"icon": "building-store",
|
| 174 |
+
})
|
| 175 |
+
else:
|
| 176 |
+
pts = 0
|
| 177 |
+
risk_points += pts
|
| 178 |
+
|
| 179 |
+
# ── Signal 5: Review count cluster check ─────────────────────────────────
|
| 180 |
+
# Certain review counts are suspicious (round numbers like exactly 1000, 2000
|
| 181 |
+
# or very specific patterns from bulk review services)
|
| 182 |
+
max_points += 15
|
| 183 |
+
suspicious_counts = [100, 200, 500, 1000, 1500, 2000, 2500, 3000, 5000]
|
| 184 |
+
if any(abs(review_count - sc) <= 2 for sc in suspicious_counts) and review_count > 50:
|
| 185 |
+
pts = 10
|
| 186 |
+
signals.append({
|
| 187 |
+
"name": "Suspiciously round review count",
|
| 188 |
+
"description": f"{review_count} reviews — round numbers sometimes indicate bulk review purchases that stopped at a target",
|
| 189 |
+
"severity": "low",
|
| 190 |
+
"icon": "hash",
|
| 191 |
+
})
|
| 192 |
+
else:
|
| 193 |
+
pts = 0
|
| 194 |
+
risk_points += pts
|
| 195 |
+
|
| 196 |
+
# ── Positive trust signals (reduce risk) ─────────────────────────────────
|
| 197 |
+
trust_signals = []
|
| 198 |
+
|
| 199 |
+
if rating >= 3.5 and rating <= 4.4 and review_count > 20:
|
| 200 |
+
trust_signals.append("Realistic rating range — neither perfect nor manipulated downward")
|
| 201 |
+
risk_points = max(0, risk_points - 5)
|
| 202 |
+
|
| 203 |
+
if is_fba and seller_count > 3:
|
| 204 |
+
trust_signals.append("Multiple FBA sellers — competitive market reduces manipulation incentive")
|
| 205 |
+
risk_points = max(0, risk_points - 5)
|
| 206 |
+
|
| 207 |
+
if bsr and bsr < 5000 and review_count > 100:
|
| 208 |
+
trust_signals.append("Strong BSR supports high review count — organic success likely")
|
| 209 |
+
risk_points = max(0, risk_points - 8)
|
| 210 |
+
|
| 211 |
+
# ── Calculate final scores ────────────────────────────────────────────────
|
| 212 |
+
risk_score = min(100, int((risk_points / max_points) * 100)) if max_points > 0 else 0
|
| 213 |
+
|
| 214 |
+
# Confidence: higher when we have more data points
|
| 215 |
+
data_richness = sum([
|
| 216 |
+
1 if review_count > 0 else 0,
|
| 217 |
+
1 if rating > 0 else 0,
|
| 218 |
+
1 if bsr and bsr > 0 else 0,
|
| 219 |
+
1 if monthly_sales and monthly_sales > 0 else 0,
|
| 220 |
+
])
|
| 221 |
+
confidence = int((data_richness / 4) * 85) + 10 # 10–95%
|
| 222 |
+
|
| 223 |
+
# Risk level thresholds
|
| 224 |
+
if risk_score >= 65:
|
| 225 |
+
risk_level = "very_high"
|
| 226 |
+
verdict = "High probability of fake reviews — exercise extreme caution before investing"
|
| 227 |
+
recommendation = "Do not source this product. The review count and rating pattern strongly suggests manufactured social proof."
|
| 228 |
+
elif risk_score >= 45:
|
| 229 |
+
risk_level = "high"
|
| 230 |
+
verdict = "Several suspicious patterns detected — reviews may be partially manipulated"
|
| 231 |
+
recommendation = "Investigate further. Check review dates for sudden spikes and read 1★ and 3★ reviews carefully."
|
| 232 |
+
elif risk_score >= 25:
|
| 233 |
+
risk_level = "medium"
|
| 234 |
+
verdict = "Some anomalies present — reviews are likely mostly genuine with possible padding"
|
| 235 |
+
recommendation = "Proceed with caution. Read recent reviews and check for duplicate review text."
|
| 236 |
+
else:
|
| 237 |
+
risk_level = "low"
|
| 238 |
+
verdict = "Review profile appears organic — no major red flags detected"
|
| 239 |
+
recommendation = "Reviews appear trustworthy. Standard due diligence still recommended."
|
| 240 |
+
|
| 241 |
+
trust_score = 100 - risk_score
|
| 242 |
+
|
| 243 |
+
result = {
|
| 244 |
+
"risk_score": risk_score,
|
| 245 |
+
"risk_level": risk_level,
|
| 246 |
+
"trust_score": trust_score,
|
| 247 |
+
"confidence": confidence,
|
| 248 |
+
"verdict": verdict,
|
| 249 |
+
"recommendation": recommendation,
|
| 250 |
+
"signals": signals,
|
| 251 |
+
"trust_signals": trust_signals,
|
| 252 |
+
"engine": "heuristic",
|
| 253 |
+
"model": "statistical-signals",
|
| 254 |
+
"stats": {
|
| 255 |
+
"review_count": review_count,
|
| 256 |
+
"rating": rating,
|
| 257 |
+
"bsr": bsr,
|
| 258 |
+
"monthly_sales": monthly_sales,
|
| 259 |
+
},
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
try:
|
| 263 |
+
from app.services.ml.sklearn_engine import get_engine
|
| 264 |
+
engine = get_engine()
|
| 265 |
+
if engine:
|
| 266 |
+
ml = engine.predict_fake_review_risk(
|
| 267 |
+
review_count, rating, bsr, monthly_sales, seller_count
|
| 268 |
+
)
|
| 269 |
+
blended = int(min(100, max(0, ml["risk_score"] * 0.65 + risk_score * 0.35)))
|
| 270 |
+
result["risk_score"] = blended
|
| 271 |
+
result["trust_score"] = 100 - blended
|
| 272 |
+
result["confidence"] = max(confidence, ml.get("confidence", 70))
|
| 273 |
+
result["engine"] = "sklearn+heuristic"
|
| 274 |
+
result["model"] = ml.get("model", "RandomForestClassifier")
|
| 275 |
+
result["model_version"] = ml.get("model_version")
|
| 276 |
+
if blended >= 65:
|
| 277 |
+
result["risk_level"] = "very_high"
|
| 278 |
+
elif blended >= 45:
|
| 279 |
+
result["risk_level"] = "high"
|
| 280 |
+
elif blended >= 25:
|
| 281 |
+
result["risk_level"] = "medium"
|
| 282 |
+
else:
|
| 283 |
+
result["risk_level"] = "low"
|
| 284 |
+
except Exception as e:
|
| 285 |
+
print(f"[fake_review_detector] sklearn blend failed: {e}")
|
| 286 |
+
|
| 287 |
return result
|
app/services/ml/ml_engine.py
CHANGED
|
@@ -1,509 +1,509 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Rankora ML Engine
|
| 3 |
-
4 ML Features:
|
| 4 |
-
1. Fake Review Detector
|
| 5 |
-
2. Price Prediction (Linear Regression)
|
| 6 |
-
3. Demand Forecasting (Seasonality)
|
| 7 |
-
4. Niche Scorer
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import math
|
| 11 |
-
import random
|
| 12 |
-
from typing import Optional
|
| 13 |
-
from datetime import datetime, timezone
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
# ══════════════════════════════════════════════════════════════
|
| 17 |
-
# 1. FAKE REVIEW DETECTOR
|
| 18 |
-
# ═══════════════════════════════════════════════════════════════
|
| 19 |
-
|
| 20 |
-
def detect_fake_reviews(
|
| 21 |
-
rating: float,
|
| 22 |
-
review_count: int,
|
| 23 |
-
rating_distribution: Optional[dict] = None,
|
| 24 |
-
monthly_sales_estimate: int = 100,
|
| 25 |
-
asin: str = ""
|
| 26 |
-
) -> dict:
|
| 27 |
-
"""
|
| 28 |
-
Detect suspicious review patterns using rule-based ML heuristics.
|
| 29 |
-
Returns a suspicion score 0-100 and detailed signals.
|
| 30 |
-
"""
|
| 31 |
-
signals = []
|
| 32 |
-
suspicion_score = 0
|
| 33 |
-
|
| 34 |
-
# Signal 1: Perfect or near-perfect rating with many reviews
|
| 35 |
-
if rating >= 4.8 and review_count > 500:
|
| 36 |
-
suspicion_score += 20
|
| 37 |
-
signals.append({
|
| 38 |
-
"signal": "Suspiciously High Rating",
|
| 39 |
-
"detail": f"{rating}★ with {review_count:,} reviews — real products rarely maintain 4.8+ at scale",
|
| 40 |
-
"severity": "high",
|
| 41 |
-
"weight": 20
|
| 42 |
-
})
|
| 43 |
-
elif rating >= 4.9 and review_count > 100:
|
| 44 |
-
suspicion_score += 25
|
| 45 |
-
signals.append({
|
| 46 |
-
"signal": "Near-Perfect Rating",
|
| 47 |
-
"detail": f"{rating}★ is unusually high — may indicate review manipulation",
|
| 48 |
-
"severity": "high",
|
| 49 |
-
"weight": 25
|
| 50 |
-
})
|
| 51 |
-
|
| 52 |
-
# Signal 2: Review velocity vs sales ratio
|
| 53 |
-
# If reviews >> expected for sales level, reviews may be incentivized
|
| 54 |
-
expected_reviews = monthly_sales_estimate * 0.02 # ~2% of sales leave reviews
|
| 55 |
-
if review_count > 0 and monthly_sales_estimate > 0:
|
| 56 |
-
review_rate = review_count / max(monthly_sales_estimate * 6, 1) # 6 months
|
| 57 |
-
if review_rate > 0.15: # more than 15% of buyers reviewing is suspicious
|
| 58 |
-
suspicion_score += 20
|
| 59 |
-
signals.append({
|
| 60 |
-
"signal": "High Review Velocity",
|
| 61 |
-
"detail": f"Review rate ({review_rate:.1%}) is unusually high vs estimated sales — possible incentivized reviews",
|
| 62 |
-
"severity": "medium",
|
| 63 |
-
"weight": 20
|
| 64 |
-
})
|
| 65 |
-
|
| 66 |
-
# Signal 3: Very low review count but high BSR
|
| 67 |
-
# High sales rank but very few reviews = possibly review reset / new ASIN
|
| 68 |
-
if monthly_sales_estimate > 500 and review_count < 20:
|
| 69 |
-
suspicion_score += 15
|
| 70 |
-
signals.append({
|
| 71 |
-
"signal": "Sales/Review Mismatch",
|
| 72 |
-
"detail": f"High estimated sales ({monthly_sales_estimate:,}/mo) but only {review_count} reviews — possible review manipulation reset",
|
| 73 |
-
"severity": "medium",
|
| 74 |
-
"weight": 15
|
| 75 |
-
})
|
| 76 |
-
|
| 77 |
-
# Signal 4: Rating distribution analysis (if provided)
|
| 78 |
-
if rating_distribution:
|
| 79 |
-
five_star_pct = rating_distribution.get("5_star", 0)
|
| 80 |
-
one_star_pct = rating_distribution.get("1_star", 0)
|
| 81 |
-
if five_star_pct > 85:
|
| 82 |
-
suspicion_score += 20
|
| 83 |
-
signals.append({
|
| 84 |
-
"signal": "Extreme 5-Star Concentration",
|
| 85 |
-
"detail": f"{five_star_pct}% five-star reviews — legitimate products rarely exceed 80%",
|
| 86 |
-
"severity": "high",
|
| 87 |
-
"weight": 20
|
| 88 |
-
})
|
| 89 |
-
if one_star_pct < 1 and review_count > 200:
|
| 90 |
-
suspicion_score += 10
|
| 91 |
-
signals.append({
|
| 92 |
-
"signal": "Missing Negative Reviews",
|
| 93 |
-
"detail": f"Only {one_star_pct}% 1-star reviews out of {review_count:,} — statistically unlikely for real products",
|
| 94 |
-
"severity": "medium",
|
| 95 |
-
"weight": 10
|
| 96 |
-
})
|
| 97 |
-
|
| 98 |
-
# Signal 5: Round number review counts often indicate manipulation
|
| 99 |
-
if review_count > 100:
|
| 100 |
-
str_count = str(review_count)
|
| 101 |
-
trailing_zeros = len(str_count) - len(str_count.rstrip("0"))
|
| 102 |
-
if trailing_zeros >= 2:
|
| 103 |
-
suspicion_score += 10
|
| 104 |
-
signals.append({
|
| 105 |
-
"signal": "Suspicious Review Count",
|
| 106 |
-
"detail": f"Round number ({review_count:,}) with trailing zeros — may indicate manipulated count",
|
| 107 |
-
"severity": "low",
|
| 108 |
-
"weight": 10
|
| 109 |
-
})
|
| 110 |
-
|
| 111 |
-
suspicion_score = min(suspicion_score, 100)
|
| 112 |
-
|
| 113 |
-
if suspicion_score >= 60:
|
| 114 |
-
verdict = "HIGH RISK — Likely Fake Reviews"
|
| 115 |
-
verdict_color = "#EF4444"
|
| 116 |
-
recommendation = "Avoid this product — high probability of review manipulation"
|
| 117 |
-
elif suspicion_score >= 35:
|
| 118 |
-
verdict = "MODERATE RISK — Suspicious Patterns"
|
| 119 |
-
verdict_color = "#F59E0B"
|
| 120 |
-
recommendation = "Investigate further before competing in this space"
|
| 121 |
-
elif suspicion_score >= 15:
|
| 122 |
-
verdict = "LOW RISK — Minor Concerns"
|
| 123 |
-
verdict_color = "#FB923C"
|
| 124 |
-
recommendation = "Reviews appear mostly genuine with minor anomalies"
|
| 125 |
-
else:
|
| 126 |
-
verdict = "AUTHENTIC — Reviews Appear Genuine"
|
| 127 |
-
verdict_color = "#10B981"
|
| 128 |
-
recommendation = "No significant red flags detected in review patterns"
|
| 129 |
-
|
| 130 |
-
return {
|
| 131 |
-
"suspicion_score": suspicion_score,
|
| 132 |
-
"verdict": verdict,
|
| 133 |
-
"verdict_color": verdict_color,
|
| 134 |
-
"recommendation": recommendation,
|
| 135 |
-
"signals": signals,
|
| 136 |
-
"signals_count": len(signals),
|
| 137 |
-
"is_suspicious": suspicion_score >= 35,
|
| 138 |
-
"analysis_basis": {
|
| 139 |
-
"rating": rating,
|
| 140 |
-
"review_count": review_count,
|
| 141 |
-
"monthly_sales_estimate": monthly_sales_estimate,
|
| 142 |
-
}
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
# ═══════════════════════════════════════════════════════════════
|
| 147 |
-
# 2. PRICE PREDICTION (Linear Regression)
|
| 148 |
-
# ═══════════════════════════════════════════════════════════════
|
| 149 |
-
|
| 150 |
-
def predict_price(
|
| 151 |
-
price_history: list,
|
| 152 |
-
days_ahead: int = 7
|
| 153 |
-
) -> dict:
|
| 154 |
-
"""
|
| 155 |
-
Predict future price using linear regression on price history.
|
| 156 |
-
price_history: list of {"price": float, "recorded_at": str}
|
| 157 |
-
"""
|
| 158 |
-
if not price_history or len(price_history) < 3:
|
| 159 |
-
return {
|
| 160 |
-
"predicted_price": None,
|
| 161 |
-
"confidence": "low",
|
| 162 |
-
"trend": "insufficient_data",
|
| 163 |
-
"message": "Need at least 3 data points for prediction"
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
# Extract prices
|
| 167 |
-
prices = [float(p["price"]) for p in price_history if p.get("price")]
|
| 168 |
-
if len(prices) < 3:
|
| 169 |
-
return {"predicted_price": None, "confidence": "low", "trend": "insufficient_data"}
|
| 170 |
-
|
| 171 |
-
n = len(prices)
|
| 172 |
-
x = list(range(n))
|
| 173 |
-
|
| 174 |
-
# Linear regression: y = mx + b
|
| 175 |
-
sum_x = sum(x)
|
| 176 |
-
sum_y = sum(prices)
|
| 177 |
-
sum_xy = sum(x[i] * prices[i] for i in range(n))
|
| 178 |
-
sum_x2 = sum(xi ** 2 for xi in x)
|
| 179 |
-
|
| 180 |
-
denom = n * sum_x2 - sum_x ** 2
|
| 181 |
-
if denom == 0:
|
| 182 |
-
slope = 0
|
| 183 |
-
else:
|
| 184 |
-
slope = (n * sum_xy - sum_x * sum_y) / denom
|
| 185 |
-
intercept = (sum_y - slope * sum_x) / n
|
| 186 |
-
|
| 187 |
-
# Predict future price
|
| 188 |
-
future_x = n - 1 + days_ahead
|
| 189 |
-
predicted = round(intercept + slope * future_x, 2)
|
| 190 |
-
predicted = max(0.01, predicted) # price can't be negative
|
| 191 |
-
|
| 192 |
-
# Calculate R² for confidence
|
| 193 |
-
y_mean = sum_y / n
|
| 194 |
-
ss_tot = sum((p - y_mean) ** 2 for p in prices)
|
| 195 |
-
ss_res = sum((prices[i] - (intercept + slope * x[i])) ** 2 for i in range(n))
|
| 196 |
-
r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
|
| 197 |
-
|
| 198 |
-
# Determine trend
|
| 199 |
-
current_price = prices[-1]
|
| 200 |
-
price_change_pct = ((predicted - current_price) / current_price) * 100
|
| 201 |
-
|
| 202 |
-
if slope > 0.01:
|
| 203 |
-
trend = "rising"
|
| 204 |
-
trend_emoji = "📈"
|
| 205 |
-
trend_color = "#EF4444"
|
| 206 |
-
elif slope < -0.01:
|
| 207 |
-
trend = "falling"
|
| 208 |
-
trend_emoji = "📉"
|
| 209 |
-
trend_color = "#10B981"
|
| 210 |
-
else:
|
| 211 |
-
trend = "stable"
|
| 212 |
-
trend_emoji = "➡️"
|
| 213 |
-
trend_color = "#6B7280"
|
| 214 |
-
|
| 215 |
-
confidence = "high" if r2 > 0.7 else "medium" if r2 > 0.4 else "low"
|
| 216 |
-
|
| 217 |
-
# Price stats
|
| 218 |
-
min_price = min(prices)
|
| 219 |
-
max_price = max(prices)
|
| 220 |
-
avg_price = sum(prices) / len(prices)
|
| 221 |
-
|
| 222 |
-
return {
|
| 223 |
-
"predicted_price": predicted,
|
| 224 |
-
"current_price": current_price,
|
| 225 |
-
"price_change": round(predicted - current_price, 2),
|
| 226 |
-
"price_change_pct": round(price_change_pct, 1),
|
| 227 |
-
"trend": trend,
|
| 228 |
-
"trend_emoji": trend_emoji,
|
| 229 |
-
"trend_color": trend_color,
|
| 230 |
-
"confidence": confidence,
|
| 231 |
-
"r_squared": round(r2, 3),
|
| 232 |
-
"days_ahead": days_ahead,
|
| 233 |
-
"slope_per_day": round(slope, 4),
|
| 234 |
-
"price_stats": {
|
| 235 |
-
"min": round(min_price, 2),
|
| 236 |
-
"max": round(max_price, 2),
|
| 237 |
-
"avg": round(avg_price, 2),
|
| 238 |
-
"volatility": round((max_price - min_price) / avg_price * 100, 1)
|
| 239 |
-
},
|
| 240 |
-
"recommendation": (
|
| 241 |
-
f"Price expected to {'rise' if trend == 'rising' else 'fall' if trend == 'falling' else 'stay stable'} "
|
| 242 |
-
f"by {abs(price_change_pct):.1f}% over next {days_ahead} days"
|
| 243 |
-
)
|
| 244 |
-
}
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
# ═══════════════════════════════════════════════════════════════
|
| 248 |
-
# 3. DEMAND FORECASTING (Seasonality Detection)
|
| 249 |
-
# ═══════════════════════════════════════════════════════════════
|
| 250 |
-
|
| 251 |
-
def forecast_demand(
|
| 252 |
-
bsr_history: list,
|
| 253 |
-
category: str = "general",
|
| 254 |
-
current_month: Optional[int] = None
|
| 255 |
-
) -> dict:
|
| 256 |
-
"""
|
| 257 |
-
Forecast demand using BSR trends and seasonal patterns.
|
| 258 |
-
Lower BSR = higher demand.
|
| 259 |
-
"""
|
| 260 |
-
if current_month is None:
|
| 261 |
-
current_month = datetime.now(timezone.utc).month
|
| 262 |
-
|
| 263 |
-
# Category seasonal multipliers (month 1-12)
|
| 264 |
-
seasonal_patterns = {
|
| 265 |
-
"electronics": [0.8, 0.7, 0.8, 0.9, 0.9, 0.8, 0.9, 0.9, 1.0, 1.0, 1.3, 1.4],
|
| 266 |
-
"home": [0.9, 0.8, 1.0, 1.1, 1.2, 1.1, 1.0, 0.9, 0.9, 1.0, 1.1, 1.0],
|
| 267 |
-
"toys": [0.7, 0.6, 0.7, 0.7, 0.8, 0.8, 0.9, 0.9, 1.0, 1.1, 1.3, 1.8],
|
| 268 |
-
"sports": [0.9, 0.9, 1.1, 1.2, 1.3, 1.3, 1.2, 1.1, 1.0, 0.9, 0.8, 0.8],
|
| 269 |
-
"kitchen": [0.9, 0.9, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.9, 1.0, 1.1, 1.2],
|
| 270 |
-
"general": [1.0, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.2],
|
| 271 |
-
}
|
| 272 |
-
|
| 273 |
-
# Match category
|
| 274 |
-
cat_key = "general"
|
| 275 |
-
category_lower = category.lower()
|
| 276 |
-
for key in seasonal_patterns:
|
| 277 |
-
if key in category_lower:
|
| 278 |
-
cat_key = key
|
| 279 |
-
break
|
| 280 |
-
|
| 281 |
-
pattern = seasonal_patterns[cat_key]
|
| 282 |
-
current_multiplier = pattern[current_month - 1]
|
| 283 |
-
next_month = (current_month % 12)
|
| 284 |
-
next_multiplier = pattern[next_month]
|
| 285 |
-
|
| 286 |
-
# BSR trend analysis
|
| 287 |
-
bsr_trend = "stable"
|
| 288 |
-
bsr_change_pct = 0
|
| 289 |
-
demand_trend = "stable"
|
| 290 |
-
|
| 291 |
-
if bsr_history and len(bsr_history) >= 3:
|
| 292 |
-
bsr_values = [h.get("bsr") for h in bsr_history if h.get("bsr")]
|
| 293 |
-
if len(bsr_values) >= 3:
|
| 294 |
-
old_bsr = sum(bsr_values[:3]) / 3
|
| 295 |
-
new_bsr = sum(bsr_values[-3:]) / 3
|
| 296 |
-
bsr_change_pct = ((new_bsr - old_bsr) / old_bsr) * 100
|
| 297 |
-
|
| 298 |
-
if bsr_change_pct < -10:
|
| 299 |
-
bsr_trend = "improving" # BSR going down = demand going up
|
| 300 |
-
demand_trend = "increasing"
|
| 301 |
-
elif bsr_change_pct > 10:
|
| 302 |
-
bsr_trend = "declining"
|
| 303 |
-
demand_trend = "decreasing"
|
| 304 |
-
else:
|
| 305 |
-
bsr_trend = "stable"
|
| 306 |
-
demand_trend = "stable"
|
| 307 |
-
|
| 308 |
-
# Seasonal score for each month
|
| 309 |
-
months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
|
| 310 |
-
monthly_forecast = []
|
| 311 |
-
for i, mult in enumerate(pattern):
|
| 312 |
-
monthly_forecast.append({
|
| 313 |
-
"month": months[i],
|
| 314 |
-
"demand_index": round(mult * 100),
|
| 315 |
-
"is_peak": mult >= 1.2,
|
| 316 |
-
"is_low": mult <= 0.8,
|
| 317 |
-
})
|
| 318 |
-
|
| 319 |
-
peak_months = [months[i] for i, m in enumerate(pattern) if m >= 1.2]
|
| 320 |
-
low_months = [months[i] for i, m in enumerate(pattern) if m <= 0.8]
|
| 321 |
-
|
| 322 |
-
if current_multiplier >= 1.2:
|
| 323 |
-
season_status = "PEAK SEASON"
|
| 324 |
-
season_color = "#10B981"
|
| 325 |
-
season_advice = "Great time to sell — high demand period"
|
| 326 |
-
elif current_multiplier >= 1.0:
|
| 327 |
-
season_status = "NORMAL SEASON"
|
| 328 |
-
season_color = "#3B82F6"
|
| 329 |
-
season_advice = "Average demand — maintain stock levels"
|
| 330 |
-
elif current_multiplier >= 0.85:
|
| 331 |
-
season_status = "SLOW SEASON"
|
| 332 |
-
season_color = "#F59E0B"
|
| 333 |
-
season_advice = "Reduce inventory — lower demand period"
|
| 334 |
-
else:
|
| 335 |
-
season_status = "OFF SEASON"
|
| 336 |
-
season_color = "#EF4444"
|
| 337 |
-
season_advice = "Avoid heavy stock — very low demand"
|
| 338 |
-
|
| 339 |
-
demand_change_next = round((next_multiplier - current_multiplier) / current_multiplier * 100, 1)
|
| 340 |
-
|
| 341 |
-
return {
|
| 342 |
-
"current_season": season_status,
|
| 343 |
-
"season_color": season_color,
|
| 344 |
-
"season_advice": season_advice,
|
| 345 |
-
"current_demand_index": round(current_multiplier * 100),
|
| 346 |
-
"next_month_demand_index": round(next_multiplier * 100),
|
| 347 |
-
"demand_change_next_month": demand_change_next,
|
| 348 |
-
"demand_trend": demand_trend,
|
| 349 |
-
"bsr_trend": bsr_trend,
|
| 350 |
-
"bsr_change_pct": round(bsr_change_pct, 1),
|
| 351 |
-
"category": cat_key,
|
| 352 |
-
"peak_months": peak_months,
|
| 353 |
-
"low_months": low_months,
|
| 354 |
-
"monthly_forecast": monthly_forecast,
|
| 355 |
-
"recommendation": (
|
| 356 |
-
f"{season_status}: {season_advice}. "
|
| 357 |
-
f"Next month demand expected to {'increase' if demand_change_next > 5 else 'decrease' if demand_change_next < -5 else 'stay similar'} "
|
| 358 |
-
f"by {abs(demand_change_next):.0f}%."
|
| 359 |
-
)
|
| 360 |
-
}
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
# ═══════════════════════════════════════════════════════════════
|
| 364 |
-
# 4. NICHE SCORER
|
| 365 |
-
# ═══════════════════════════════════════════════════════════════
|
| 366 |
-
|
| 367 |
-
def score_niche(
|
| 368 |
-
keyword: str,
|
| 369 |
-
products: list, # list of {bsr, price, reviews, rating}
|
| 370 |
-
category: str = "general"
|
| 371 |
-
) -> dict:
|
| 372 |
-
"""
|
| 373 |
-
Score an entire niche/keyword based on multiple product data points.
|
| 374 |
-
Returns opportunity score 0-100 and detailed breakdown.
|
| 375 |
-
"""
|
| 376 |
-
if not products:
|
| 377 |
-
return {"error": "No products provided for niche analysis"}
|
| 378 |
-
|
| 379 |
-
prices = [p.get("price", 0) for p in products if p.get("price")]
|
| 380 |
-
bsrs = [p.get("bsr", 0) for p in products if p.get("bsr")]
|
| 381 |
-
reviews = [p.get("reviews", 0) for p in products if p.get("reviews") is not None]
|
| 382 |
-
ratings = [p.get("rating", 0) for p in products if p.get("rating")]
|
| 383 |
-
|
| 384 |
-
def avg(lst): return sum(lst) / len(lst) if lst else 0
|
| 385 |
-
def median(lst):
|
| 386 |
-
s = sorted(lst)
|
| 387 |
-
n = len(s)
|
| 388 |
-
return (s[n//2] + s[n//2-1]) / 2 if n % 2 == 0 else s[n//2]
|
| 389 |
-
|
| 390 |
-
avg_price = avg(prices)
|
| 391 |
-
avg_bsr = avg(bsrs)
|
| 392 |
-
avg_reviews = avg(reviews)
|
| 393 |
-
avg_rating = avg(ratings)
|
| 394 |
-
med_reviews = median(reviews) if reviews else 0
|
| 395 |
-
|
| 396 |
-
scores = {}
|
| 397 |
-
|
| 398 |
-
# 1. Demand Score (based on BSR)
|
| 399 |
-
if avg_bsr < 1000:
|
| 400 |
-
scores["demand"] = 95
|
| 401 |
-
elif avg_bsr < 5000:
|
| 402 |
-
scores["demand"] = 80
|
| 403 |
-
elif avg_bsr < 20000:
|
| 404 |
-
scores["demand"] = 65
|
| 405 |
-
elif avg_bsr < 100000:
|
| 406 |
-
scores["demand"] = 45
|
| 407 |
-
else:
|
| 408 |
-
scores["demand"] = 20
|
| 409 |
-
|
| 410 |
-
# 2. Competition Score (lower reviews = easier to compete)
|
| 411 |
-
if avg_reviews < 50:
|
| 412 |
-
scores["competition"] = 90
|
| 413 |
-
elif avg_reviews < 200:
|
| 414 |
-
scores["competition"] = 75
|
| 415 |
-
elif avg_reviews < 500:
|
| 416 |
-
scores["competition"] = 55
|
| 417 |
-
elif avg_reviews < 2000:
|
| 418 |
-
scores["competition"] = 35
|
| 419 |
-
else:
|
| 420 |
-
scores["competition"] = 15
|
| 421 |
-
|
| 422 |
-
# 3. Profitability Score (based on price)
|
| 423 |
-
if avg_price >= 25 and avg_price <= 70:
|
| 424 |
-
scores["profitability"] = 85
|
| 425 |
-
elif avg_price >= 15 and avg_price < 25:
|
| 426 |
-
scores["profitability"] = 65
|
| 427 |
-
elif avg_price >= 70 and avg_price <= 150:
|
| 428 |
-
scores["profitability"] = 70
|
| 429 |
-
elif avg_price > 150:
|
| 430 |
-
scores["profitability"] = 50
|
| 431 |
-
else:
|
| 432 |
-
scores["profitability"] = 35
|
| 433 |
-
|
| 434 |
-
# 4. Quality Gap Score (lower ratings = easier to beat with better product)
|
| 435 |
-
if avg_rating < 3.8:
|
| 436 |
-
scores["quality_gap"] = 90
|
| 437 |
-
elif avg_rating < 4.2:
|
| 438 |
-
scores["quality_gap"] = 70
|
| 439 |
-
elif avg_rating < 4.5:
|
| 440 |
-
scores["quality_gap"] = 50
|
| 441 |
-
else:
|
| 442 |
-
scores["quality_gap"] = 25
|
| 443 |
-
|
| 444 |
-
# 5. Market Size Score
|
| 445 |
-
n = len(products)
|
| 446 |
-
if n >= 10:
|
| 447 |
-
scores["market_size"] = 80
|
| 448 |
-
elif n >= 5:
|
| 449 |
-
scores["market_size"] = 60
|
| 450 |
-
else:
|
| 451 |
-
scores["market_size"] = 40
|
| 452 |
-
|
| 453 |
-
# Weighted total
|
| 454 |
-
total = (
|
| 455 |
-
scores["demand"] * 0.30 +
|
| 456 |
-
scores["competition"] * 0.30 +
|
| 457 |
-
scores["profitability"]* 0.20 +
|
| 458 |
-
scores["quality_gap"] * 0.15 +
|
| 459 |
-
scores["market_size"] * 0.05
|
| 460 |
-
)
|
| 461 |
-
total = round(total)
|
| 462 |
-
|
| 463 |
-
if total >= 70:
|
| 464 |
-
verdict = "Excellent Niche"
|
| 465 |
-
verdict_color = "#10B981"
|
| 466 |
-
recommendation = "Strong opportunity — low competition, good demand, profitable price range"
|
| 467 |
-
elif total >= 55:
|
| 468 |
-
verdict = "Good Niche"
|
| 469 |
-
verdict_color = "#3B82F6"
|
| 470 |
-
recommendation = "Decent opportunity — worth pursuing with right differentiation"
|
| 471 |
-
elif total >= 40:
|
| 472 |
-
verdict = "Moderate Niche"
|
| 473 |
-
verdict_color = "#F59E0B"
|
| 474 |
-
recommendation = "Average opportunity — possible but competitive, needs strong USP"
|
| 475 |
-
else:
|
| 476 |
-
verdict = "Tough Niche"
|
| 477 |
-
verdict_color = "#EF4444"
|
| 478 |
-
recommendation = "Difficult market — high competition or low margins"
|
| 479 |
-
|
| 480 |
-
# Review gaps — products with < 100 reviews in a market with demand
|
| 481 |
-
low_review_products = [p for p in products if (p.get("reviews") or 0) < 100 and (p.get("bsr") or 999999) < 50000]
|
| 482 |
-
|
| 483 |
-
return {
|
| 484 |
-
"keyword": keyword,
|
| 485 |
-
"niche_score": total,
|
| 486 |
-
"verdict": verdict,
|
| 487 |
-
"verdict_color": verdict_color,
|
| 488 |
-
"recommendation": recommendation,
|
| 489 |
-
"scores": {
|
| 490 |
-
"demand": {"score": scores["demand"], "label": "Market Demand", "weight": "30%"},
|
| 491 |
-
"competition": {"score": scores["competition"], "label": "Competition Level", "weight": "30%"},
|
| 492 |
-
"profitability": {"score": scores["profitability"], "label": "Profitability", "weight": "20%"},
|
| 493 |
-
"quality_gap": {"score": scores["quality_gap"], "label": "Quality Gap", "weight": "15%"},
|
| 494 |
-
"market_size": {"score": scores["market_size"], "label": "Market Size", "weight": "5%"},
|
| 495 |
-
},
|
| 496 |
-
"market_stats": {
|
| 497 |
-
"products_analyzed": len(products),
|
| 498 |
-
"avg_price": round(avg_price, 2),
|
| 499 |
-
"avg_bsr": round(avg_bsr),
|
| 500 |
-
"avg_reviews": round(avg_reviews),
|
| 501 |
-
"avg_rating": round(avg_rating, 1),
|
| 502 |
-
"median_reviews": round(med_reviews),
|
| 503 |
-
},
|
| 504 |
-
"opportunities": {
|
| 505 |
-
"low_review_opportunities": len(low_review_products),
|
| 506 |
-
"easy_entry_products": [p.get("asin", "") for p in low_review_products[:3]],
|
| 507 |
-
"price_gap": round(max(prices) - min(prices), 2) if prices else 0,
|
| 508 |
-
}
|
| 509 |
-
}
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rankora ML Engine
|
| 3 |
+
4 ML Features:
|
| 4 |
+
1. Fake Review Detector
|
| 5 |
+
2. Price Prediction (Linear Regression)
|
| 6 |
+
3. Demand Forecasting (Seasonality)
|
| 7 |
+
4. Niche Scorer
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
import random
|
| 12 |
+
from typing import Optional
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ═════════════════════════════════════════════��═════════════════
|
| 17 |
+
# 1. FAKE REVIEW DETECTOR
|
| 18 |
+
# ═══════════════════════════════════════════════════════════════
|
| 19 |
+
|
| 20 |
+
def detect_fake_reviews(
|
| 21 |
+
rating: float,
|
| 22 |
+
review_count: int,
|
| 23 |
+
rating_distribution: Optional[dict] = None,
|
| 24 |
+
monthly_sales_estimate: int = 100,
|
| 25 |
+
asin: str = ""
|
| 26 |
+
) -> dict:
|
| 27 |
+
"""
|
| 28 |
+
Detect suspicious review patterns using rule-based ML heuristics.
|
| 29 |
+
Returns a suspicion score 0-100 and detailed signals.
|
| 30 |
+
"""
|
| 31 |
+
signals = []
|
| 32 |
+
suspicion_score = 0
|
| 33 |
+
|
| 34 |
+
# Signal 1: Perfect or near-perfect rating with many reviews
|
| 35 |
+
if rating >= 4.8 and review_count > 500:
|
| 36 |
+
suspicion_score += 20
|
| 37 |
+
signals.append({
|
| 38 |
+
"signal": "Suspiciously High Rating",
|
| 39 |
+
"detail": f"{rating}★ with {review_count:,} reviews — real products rarely maintain 4.8+ at scale",
|
| 40 |
+
"severity": "high",
|
| 41 |
+
"weight": 20
|
| 42 |
+
})
|
| 43 |
+
elif rating >= 4.9 and review_count > 100:
|
| 44 |
+
suspicion_score += 25
|
| 45 |
+
signals.append({
|
| 46 |
+
"signal": "Near-Perfect Rating",
|
| 47 |
+
"detail": f"{rating}★ is unusually high — may indicate review manipulation",
|
| 48 |
+
"severity": "high",
|
| 49 |
+
"weight": 25
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
# Signal 2: Review velocity vs sales ratio
|
| 53 |
+
# If reviews >> expected for sales level, reviews may be incentivized
|
| 54 |
+
expected_reviews = monthly_sales_estimate * 0.02 # ~2% of sales leave reviews
|
| 55 |
+
if review_count > 0 and monthly_sales_estimate > 0:
|
| 56 |
+
review_rate = review_count / max(monthly_sales_estimate * 6, 1) # 6 months
|
| 57 |
+
if review_rate > 0.15: # more than 15% of buyers reviewing is suspicious
|
| 58 |
+
suspicion_score += 20
|
| 59 |
+
signals.append({
|
| 60 |
+
"signal": "High Review Velocity",
|
| 61 |
+
"detail": f"Review rate ({review_rate:.1%}) is unusually high vs estimated sales — possible incentivized reviews",
|
| 62 |
+
"severity": "medium",
|
| 63 |
+
"weight": 20
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
# Signal 3: Very low review count but high BSR
|
| 67 |
+
# High sales rank but very few reviews = possibly review reset / new ASIN
|
| 68 |
+
if monthly_sales_estimate > 500 and review_count < 20:
|
| 69 |
+
suspicion_score += 15
|
| 70 |
+
signals.append({
|
| 71 |
+
"signal": "Sales/Review Mismatch",
|
| 72 |
+
"detail": f"High estimated sales ({monthly_sales_estimate:,}/mo) but only {review_count} reviews — possible review manipulation reset",
|
| 73 |
+
"severity": "medium",
|
| 74 |
+
"weight": 15
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
# Signal 4: Rating distribution analysis (if provided)
|
| 78 |
+
if rating_distribution:
|
| 79 |
+
five_star_pct = rating_distribution.get("5_star", 0)
|
| 80 |
+
one_star_pct = rating_distribution.get("1_star", 0)
|
| 81 |
+
if five_star_pct > 85:
|
| 82 |
+
suspicion_score += 20
|
| 83 |
+
signals.append({
|
| 84 |
+
"signal": "Extreme 5-Star Concentration",
|
| 85 |
+
"detail": f"{five_star_pct}% five-star reviews — legitimate products rarely exceed 80%",
|
| 86 |
+
"severity": "high",
|
| 87 |
+
"weight": 20
|
| 88 |
+
})
|
| 89 |
+
if one_star_pct < 1 and review_count > 200:
|
| 90 |
+
suspicion_score += 10
|
| 91 |
+
signals.append({
|
| 92 |
+
"signal": "Missing Negative Reviews",
|
| 93 |
+
"detail": f"Only {one_star_pct}% 1-star reviews out of {review_count:,} — statistically unlikely for real products",
|
| 94 |
+
"severity": "medium",
|
| 95 |
+
"weight": 10
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
# Signal 5: Round number review counts often indicate manipulation
|
| 99 |
+
if review_count > 100:
|
| 100 |
+
str_count = str(review_count)
|
| 101 |
+
trailing_zeros = len(str_count) - len(str_count.rstrip("0"))
|
| 102 |
+
if trailing_zeros >= 2:
|
| 103 |
+
suspicion_score += 10
|
| 104 |
+
signals.append({
|
| 105 |
+
"signal": "Suspicious Review Count",
|
| 106 |
+
"detail": f"Round number ({review_count:,}) with trailing zeros — may indicate manipulated count",
|
| 107 |
+
"severity": "low",
|
| 108 |
+
"weight": 10
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
suspicion_score = min(suspicion_score, 100)
|
| 112 |
+
|
| 113 |
+
if suspicion_score >= 60:
|
| 114 |
+
verdict = "HIGH RISK — Likely Fake Reviews"
|
| 115 |
+
verdict_color = "#EF4444"
|
| 116 |
+
recommendation = "Avoid this product — high probability of review manipulation"
|
| 117 |
+
elif suspicion_score >= 35:
|
| 118 |
+
verdict = "MODERATE RISK — Suspicious Patterns"
|
| 119 |
+
verdict_color = "#F59E0B"
|
| 120 |
+
recommendation = "Investigate further before competing in this space"
|
| 121 |
+
elif suspicion_score >= 15:
|
| 122 |
+
verdict = "LOW RISK — Minor Concerns"
|
| 123 |
+
verdict_color = "#FB923C"
|
| 124 |
+
recommendation = "Reviews appear mostly genuine with minor anomalies"
|
| 125 |
+
else:
|
| 126 |
+
verdict = "AUTHENTIC — Reviews Appear Genuine"
|
| 127 |
+
verdict_color = "#10B981"
|
| 128 |
+
recommendation = "No significant red flags detected in review patterns"
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"suspicion_score": suspicion_score,
|
| 132 |
+
"verdict": verdict,
|
| 133 |
+
"verdict_color": verdict_color,
|
| 134 |
+
"recommendation": recommendation,
|
| 135 |
+
"signals": signals,
|
| 136 |
+
"signals_count": len(signals),
|
| 137 |
+
"is_suspicious": suspicion_score >= 35,
|
| 138 |
+
"analysis_basis": {
|
| 139 |
+
"rating": rating,
|
| 140 |
+
"review_count": review_count,
|
| 141 |
+
"monthly_sales_estimate": monthly_sales_estimate,
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ═══════════════════════════════════════════════════════════════
|
| 147 |
+
# 2. PRICE PREDICTION (Linear Regression)
|
| 148 |
+
# ═══════════════════════════════════════════════════════════════
|
| 149 |
+
|
| 150 |
+
def predict_price(
|
| 151 |
+
price_history: list,
|
| 152 |
+
days_ahead: int = 7
|
| 153 |
+
) -> dict:
|
| 154 |
+
"""
|
| 155 |
+
Predict future price using linear regression on price history.
|
| 156 |
+
price_history: list of {"price": float, "recorded_at": str}
|
| 157 |
+
"""
|
| 158 |
+
if not price_history or len(price_history) < 3:
|
| 159 |
+
return {
|
| 160 |
+
"predicted_price": None,
|
| 161 |
+
"confidence": "low",
|
| 162 |
+
"trend": "insufficient_data",
|
| 163 |
+
"message": "Need at least 3 data points for prediction"
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
# Extract prices
|
| 167 |
+
prices = [float(p["price"]) for p in price_history if p.get("price")]
|
| 168 |
+
if len(prices) < 3:
|
| 169 |
+
return {"predicted_price": None, "confidence": "low", "trend": "insufficient_data"}
|
| 170 |
+
|
| 171 |
+
n = len(prices)
|
| 172 |
+
x = list(range(n))
|
| 173 |
+
|
| 174 |
+
# Linear regression: y = mx + b
|
| 175 |
+
sum_x = sum(x)
|
| 176 |
+
sum_y = sum(prices)
|
| 177 |
+
sum_xy = sum(x[i] * prices[i] for i in range(n))
|
| 178 |
+
sum_x2 = sum(xi ** 2 for xi in x)
|
| 179 |
+
|
| 180 |
+
denom = n * sum_x2 - sum_x ** 2
|
| 181 |
+
if denom == 0:
|
| 182 |
+
slope = 0
|
| 183 |
+
else:
|
| 184 |
+
slope = (n * sum_xy - sum_x * sum_y) / denom
|
| 185 |
+
intercept = (sum_y - slope * sum_x) / n
|
| 186 |
+
|
| 187 |
+
# Predict future price
|
| 188 |
+
future_x = n - 1 + days_ahead
|
| 189 |
+
predicted = round(intercept + slope * future_x, 2)
|
| 190 |
+
predicted = max(0.01, predicted) # price can't be negative
|
| 191 |
+
|
| 192 |
+
# Calculate R² for confidence
|
| 193 |
+
y_mean = sum_y / n
|
| 194 |
+
ss_tot = sum((p - y_mean) ** 2 for p in prices)
|
| 195 |
+
ss_res = sum((prices[i] - (intercept + slope * x[i])) ** 2 for i in range(n))
|
| 196 |
+
r2 = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
|
| 197 |
+
|
| 198 |
+
# Determine trend
|
| 199 |
+
current_price = prices[-1]
|
| 200 |
+
price_change_pct = ((predicted - current_price) / current_price) * 100
|
| 201 |
+
|
| 202 |
+
if slope > 0.01:
|
| 203 |
+
trend = "rising"
|
| 204 |
+
trend_emoji = "📈"
|
| 205 |
+
trend_color = "#EF4444"
|
| 206 |
+
elif slope < -0.01:
|
| 207 |
+
trend = "falling"
|
| 208 |
+
trend_emoji = "📉"
|
| 209 |
+
trend_color = "#10B981"
|
| 210 |
+
else:
|
| 211 |
+
trend = "stable"
|
| 212 |
+
trend_emoji = "➡️"
|
| 213 |
+
trend_color = "#6B7280"
|
| 214 |
+
|
| 215 |
+
confidence = "high" if r2 > 0.7 else "medium" if r2 > 0.4 else "low"
|
| 216 |
+
|
| 217 |
+
# Price stats
|
| 218 |
+
min_price = min(prices)
|
| 219 |
+
max_price = max(prices)
|
| 220 |
+
avg_price = sum(prices) / len(prices)
|
| 221 |
+
|
| 222 |
+
return {
|
| 223 |
+
"predicted_price": predicted,
|
| 224 |
+
"current_price": current_price,
|
| 225 |
+
"price_change": round(predicted - current_price, 2),
|
| 226 |
+
"price_change_pct": round(price_change_pct, 1),
|
| 227 |
+
"trend": trend,
|
| 228 |
+
"trend_emoji": trend_emoji,
|
| 229 |
+
"trend_color": trend_color,
|
| 230 |
+
"confidence": confidence,
|
| 231 |
+
"r_squared": round(r2, 3),
|
| 232 |
+
"days_ahead": days_ahead,
|
| 233 |
+
"slope_per_day": round(slope, 4),
|
| 234 |
+
"price_stats": {
|
| 235 |
+
"min": round(min_price, 2),
|
| 236 |
+
"max": round(max_price, 2),
|
| 237 |
+
"avg": round(avg_price, 2),
|
| 238 |
+
"volatility": round((max_price - min_price) / avg_price * 100, 1)
|
| 239 |
+
},
|
| 240 |
+
"recommendation": (
|
| 241 |
+
f"Price expected to {'rise' if trend == 'rising' else 'fall' if trend == 'falling' else 'stay stable'} "
|
| 242 |
+
f"by {abs(price_change_pct):.1f}% over next {days_ahead} days"
|
| 243 |
+
)
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ═══════════════════════════════════════════════════════════════
|
| 248 |
+
# 3. DEMAND FORECASTING (Seasonality Detection)
|
| 249 |
+
# ═══════════════════════════════════════════════════════════════
|
| 250 |
+
|
| 251 |
+
def forecast_demand(
|
| 252 |
+
bsr_history: list,
|
| 253 |
+
category: str = "general",
|
| 254 |
+
current_month: Optional[int] = None
|
| 255 |
+
) -> dict:
|
| 256 |
+
"""
|
| 257 |
+
Forecast demand using BSR trends and seasonal patterns.
|
| 258 |
+
Lower BSR = higher demand.
|
| 259 |
+
"""
|
| 260 |
+
if current_month is None:
|
| 261 |
+
current_month = datetime.now(timezone.utc).month
|
| 262 |
+
|
| 263 |
+
# Category seasonal multipliers (month 1-12)
|
| 264 |
+
seasonal_patterns = {
|
| 265 |
+
"electronics": [0.8, 0.7, 0.8, 0.9, 0.9, 0.8, 0.9, 0.9, 1.0, 1.0, 1.3, 1.4],
|
| 266 |
+
"home": [0.9, 0.8, 1.0, 1.1, 1.2, 1.1, 1.0, 0.9, 0.9, 1.0, 1.1, 1.0],
|
| 267 |
+
"toys": [0.7, 0.6, 0.7, 0.7, 0.8, 0.8, 0.9, 0.9, 1.0, 1.1, 1.3, 1.8],
|
| 268 |
+
"sports": [0.9, 0.9, 1.1, 1.2, 1.3, 1.3, 1.2, 1.1, 1.0, 0.9, 0.8, 0.8],
|
| 269 |
+
"kitchen": [0.9, 0.9, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.9, 1.0, 1.1, 1.2],
|
| 270 |
+
"general": [1.0, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.2],
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
# Match category
|
| 274 |
+
cat_key = "general"
|
| 275 |
+
category_lower = category.lower()
|
| 276 |
+
for key in seasonal_patterns:
|
| 277 |
+
if key in category_lower:
|
| 278 |
+
cat_key = key
|
| 279 |
+
break
|
| 280 |
+
|
| 281 |
+
pattern = seasonal_patterns[cat_key]
|
| 282 |
+
current_multiplier = pattern[current_month - 1]
|
| 283 |
+
next_month = (current_month % 12)
|
| 284 |
+
next_multiplier = pattern[next_month]
|
| 285 |
+
|
| 286 |
+
# BSR trend analysis
|
| 287 |
+
bsr_trend = "stable"
|
| 288 |
+
bsr_change_pct = 0
|
| 289 |
+
demand_trend = "stable"
|
| 290 |
+
|
| 291 |
+
if bsr_history and len(bsr_history) >= 3:
|
| 292 |
+
bsr_values = [h.get("bsr") for h in bsr_history if h.get("bsr")]
|
| 293 |
+
if len(bsr_values) >= 3:
|
| 294 |
+
old_bsr = sum(bsr_values[:3]) / 3
|
| 295 |
+
new_bsr = sum(bsr_values[-3:]) / 3
|
| 296 |
+
bsr_change_pct = ((new_bsr - old_bsr) / old_bsr) * 100
|
| 297 |
+
|
| 298 |
+
if bsr_change_pct < -10:
|
| 299 |
+
bsr_trend = "improving" # BSR going down = demand going up
|
| 300 |
+
demand_trend = "increasing"
|
| 301 |
+
elif bsr_change_pct > 10:
|
| 302 |
+
bsr_trend = "declining"
|
| 303 |
+
demand_trend = "decreasing"
|
| 304 |
+
else:
|
| 305 |
+
bsr_trend = "stable"
|
| 306 |
+
demand_trend = "stable"
|
| 307 |
+
|
| 308 |
+
# Seasonal score for each month
|
| 309 |
+
months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
|
| 310 |
+
monthly_forecast = []
|
| 311 |
+
for i, mult in enumerate(pattern):
|
| 312 |
+
monthly_forecast.append({
|
| 313 |
+
"month": months[i],
|
| 314 |
+
"demand_index": round(mult * 100),
|
| 315 |
+
"is_peak": mult >= 1.2,
|
| 316 |
+
"is_low": mult <= 0.8,
|
| 317 |
+
})
|
| 318 |
+
|
| 319 |
+
peak_months = [months[i] for i, m in enumerate(pattern) if m >= 1.2]
|
| 320 |
+
low_months = [months[i] for i, m in enumerate(pattern) if m <= 0.8]
|
| 321 |
+
|
| 322 |
+
if current_multiplier >= 1.2:
|
| 323 |
+
season_status = "PEAK SEASON"
|
| 324 |
+
season_color = "#10B981"
|
| 325 |
+
season_advice = "Great time to sell — high demand period"
|
| 326 |
+
elif current_multiplier >= 1.0:
|
| 327 |
+
season_status = "NORMAL SEASON"
|
| 328 |
+
season_color = "#3B82F6"
|
| 329 |
+
season_advice = "Average demand — maintain stock levels"
|
| 330 |
+
elif current_multiplier >= 0.85:
|
| 331 |
+
season_status = "SLOW SEASON"
|
| 332 |
+
season_color = "#F59E0B"
|
| 333 |
+
season_advice = "Reduce inventory — lower demand period"
|
| 334 |
+
else:
|
| 335 |
+
season_status = "OFF SEASON"
|
| 336 |
+
season_color = "#EF4444"
|
| 337 |
+
season_advice = "Avoid heavy stock — very low demand"
|
| 338 |
+
|
| 339 |
+
demand_change_next = round((next_multiplier - current_multiplier) / current_multiplier * 100, 1)
|
| 340 |
+
|
| 341 |
+
return {
|
| 342 |
+
"current_season": season_status,
|
| 343 |
+
"season_color": season_color,
|
| 344 |
+
"season_advice": season_advice,
|
| 345 |
+
"current_demand_index": round(current_multiplier * 100),
|
| 346 |
+
"next_month_demand_index": round(next_multiplier * 100),
|
| 347 |
+
"demand_change_next_month": demand_change_next,
|
| 348 |
+
"demand_trend": demand_trend,
|
| 349 |
+
"bsr_trend": bsr_trend,
|
| 350 |
+
"bsr_change_pct": round(bsr_change_pct, 1),
|
| 351 |
+
"category": cat_key,
|
| 352 |
+
"peak_months": peak_months,
|
| 353 |
+
"low_months": low_months,
|
| 354 |
+
"monthly_forecast": monthly_forecast,
|
| 355 |
+
"recommendation": (
|
| 356 |
+
f"{season_status}: {season_advice}. "
|
| 357 |
+
f"Next month demand expected to {'increase' if demand_change_next > 5 else 'decrease' if demand_change_next < -5 else 'stay similar'} "
|
| 358 |
+
f"by {abs(demand_change_next):.0f}%."
|
| 359 |
+
)
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
# ═══════════════════════════════════════════════════════════════
|
| 364 |
+
# 4. NICHE SCORER
|
| 365 |
+
# ═══════════════════════════════════════════════════════════════
|
| 366 |
+
|
| 367 |
+
def score_niche(
|
| 368 |
+
keyword: str,
|
| 369 |
+
products: list, # list of {bsr, price, reviews, rating}
|
| 370 |
+
category: str = "general"
|
| 371 |
+
) -> dict:
|
| 372 |
+
"""
|
| 373 |
+
Score an entire niche/keyword based on multiple product data points.
|
| 374 |
+
Returns opportunity score 0-100 and detailed breakdown.
|
| 375 |
+
"""
|
| 376 |
+
if not products:
|
| 377 |
+
return {"error": "No products provided for niche analysis"}
|
| 378 |
+
|
| 379 |
+
prices = [p.get("price", 0) for p in products if p.get("price")]
|
| 380 |
+
bsrs = [p.get("bsr", 0) for p in products if p.get("bsr")]
|
| 381 |
+
reviews = [p.get("reviews", 0) for p in products if p.get("reviews") is not None]
|
| 382 |
+
ratings = [p.get("rating", 0) for p in products if p.get("rating")]
|
| 383 |
+
|
| 384 |
+
def avg(lst): return sum(lst) / len(lst) if lst else 0
|
| 385 |
+
def median(lst):
|
| 386 |
+
s = sorted(lst)
|
| 387 |
+
n = len(s)
|
| 388 |
+
return (s[n//2] + s[n//2-1]) / 2 if n % 2 == 0 else s[n//2]
|
| 389 |
+
|
| 390 |
+
avg_price = avg(prices)
|
| 391 |
+
avg_bsr = avg(bsrs)
|
| 392 |
+
avg_reviews = avg(reviews)
|
| 393 |
+
avg_rating = avg(ratings)
|
| 394 |
+
med_reviews = median(reviews) if reviews else 0
|
| 395 |
+
|
| 396 |
+
scores = {}
|
| 397 |
+
|
| 398 |
+
# 1. Demand Score (based on BSR)
|
| 399 |
+
if avg_bsr < 1000:
|
| 400 |
+
scores["demand"] = 95
|
| 401 |
+
elif avg_bsr < 5000:
|
| 402 |
+
scores["demand"] = 80
|
| 403 |
+
elif avg_bsr < 20000:
|
| 404 |
+
scores["demand"] = 65
|
| 405 |
+
elif avg_bsr < 100000:
|
| 406 |
+
scores["demand"] = 45
|
| 407 |
+
else:
|
| 408 |
+
scores["demand"] = 20
|
| 409 |
+
|
| 410 |
+
# 2. Competition Score (lower reviews = easier to compete)
|
| 411 |
+
if avg_reviews < 50:
|
| 412 |
+
scores["competition"] = 90
|
| 413 |
+
elif avg_reviews < 200:
|
| 414 |
+
scores["competition"] = 75
|
| 415 |
+
elif avg_reviews < 500:
|
| 416 |
+
scores["competition"] = 55
|
| 417 |
+
elif avg_reviews < 2000:
|
| 418 |
+
scores["competition"] = 35
|
| 419 |
+
else:
|
| 420 |
+
scores["competition"] = 15
|
| 421 |
+
|
| 422 |
+
# 3. Profitability Score (based on price)
|
| 423 |
+
if avg_price >= 25 and avg_price <= 70:
|
| 424 |
+
scores["profitability"] = 85
|
| 425 |
+
elif avg_price >= 15 and avg_price < 25:
|
| 426 |
+
scores["profitability"] = 65
|
| 427 |
+
elif avg_price >= 70 and avg_price <= 150:
|
| 428 |
+
scores["profitability"] = 70
|
| 429 |
+
elif avg_price > 150:
|
| 430 |
+
scores["profitability"] = 50
|
| 431 |
+
else:
|
| 432 |
+
scores["profitability"] = 35
|
| 433 |
+
|
| 434 |
+
# 4. Quality Gap Score (lower ratings = easier to beat with better product)
|
| 435 |
+
if avg_rating < 3.8:
|
| 436 |
+
scores["quality_gap"] = 90
|
| 437 |
+
elif avg_rating < 4.2:
|
| 438 |
+
scores["quality_gap"] = 70
|
| 439 |
+
elif avg_rating < 4.5:
|
| 440 |
+
scores["quality_gap"] = 50
|
| 441 |
+
else:
|
| 442 |
+
scores["quality_gap"] = 25
|
| 443 |
+
|
| 444 |
+
# 5. Market Size Score
|
| 445 |
+
n = len(products)
|
| 446 |
+
if n >= 10:
|
| 447 |
+
scores["market_size"] = 80
|
| 448 |
+
elif n >= 5:
|
| 449 |
+
scores["market_size"] = 60
|
| 450 |
+
else:
|
| 451 |
+
scores["market_size"] = 40
|
| 452 |
+
|
| 453 |
+
# Weighted total
|
| 454 |
+
total = (
|
| 455 |
+
scores["demand"] * 0.30 +
|
| 456 |
+
scores["competition"] * 0.30 +
|
| 457 |
+
scores["profitability"]* 0.20 +
|
| 458 |
+
scores["quality_gap"] * 0.15 +
|
| 459 |
+
scores["market_size"] * 0.05
|
| 460 |
+
)
|
| 461 |
+
total = round(total)
|
| 462 |
+
|
| 463 |
+
if total >= 70:
|
| 464 |
+
verdict = "Excellent Niche"
|
| 465 |
+
verdict_color = "#10B981"
|
| 466 |
+
recommendation = "Strong opportunity — low competition, good demand, profitable price range"
|
| 467 |
+
elif total >= 55:
|
| 468 |
+
verdict = "Good Niche"
|
| 469 |
+
verdict_color = "#3B82F6"
|
| 470 |
+
recommendation = "Decent opportunity — worth pursuing with right differentiation"
|
| 471 |
+
elif total >= 40:
|
| 472 |
+
verdict = "Moderate Niche"
|
| 473 |
+
verdict_color = "#F59E0B"
|
| 474 |
+
recommendation = "Average opportunity — possible but competitive, needs strong USP"
|
| 475 |
+
else:
|
| 476 |
+
verdict = "Tough Niche"
|
| 477 |
+
verdict_color = "#EF4444"
|
| 478 |
+
recommendation = "Difficult market — high competition or low margins"
|
| 479 |
+
|
| 480 |
+
# Review gaps — products with < 100 reviews in a market with demand
|
| 481 |
+
low_review_products = [p for p in products if (p.get("reviews") or 0) < 100 and (p.get("bsr") or 999999) < 50000]
|
| 482 |
+
|
| 483 |
+
return {
|
| 484 |
+
"keyword": keyword,
|
| 485 |
+
"niche_score": total,
|
| 486 |
+
"verdict": verdict,
|
| 487 |
+
"verdict_color": verdict_color,
|
| 488 |
+
"recommendation": recommendation,
|
| 489 |
+
"scores": {
|
| 490 |
+
"demand": {"score": scores["demand"], "label": "Market Demand", "weight": "30%"},
|
| 491 |
+
"competition": {"score": scores["competition"], "label": "Competition Level", "weight": "30%"},
|
| 492 |
+
"profitability": {"score": scores["profitability"], "label": "Profitability", "weight": "20%"},
|
| 493 |
+
"quality_gap": {"score": scores["quality_gap"], "label": "Quality Gap", "weight": "15%"},
|
| 494 |
+
"market_size": {"score": scores["market_size"], "label": "Market Size", "weight": "5%"},
|
| 495 |
+
},
|
| 496 |
+
"market_stats": {
|
| 497 |
+
"products_analyzed": len(products),
|
| 498 |
+
"avg_price": round(avg_price, 2),
|
| 499 |
+
"avg_bsr": round(avg_bsr),
|
| 500 |
+
"avg_reviews": round(avg_reviews),
|
| 501 |
+
"avg_rating": round(avg_rating, 1),
|
| 502 |
+
"median_reviews": round(med_reviews),
|
| 503 |
+
},
|
| 504 |
+
"opportunities": {
|
| 505 |
+
"low_review_opportunities": len(low_review_products),
|
| 506 |
+
"easy_entry_products": [p.get("asin", "") for p in low_review_products[:3]],
|
| 507 |
+
"price_gap": round(max(prices) - min(prices), 2) if prices else 0,
|
| 508 |
+
}
|
| 509 |
+
}
|
app/services/ml/niche_scorer.py
CHANGED
|
@@ -1,283 +1,283 @@
|
|
| 1 |
-
# app/services/ml/niche_scorer.py
|
| 2 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
-
# Niche Score — composite scoring of market opportunity.
|
| 4 |
-
# Combines demand strength, competition difficulty, profitability potential,
|
| 5 |
-
# entry barrier, and market trend into a single actionable score.
|
| 6 |
-
#
|
| 7 |
-
# Score: 0–100 where:
|
| 8 |
-
# 80–100 = Excellent niche — enter now
|
| 9 |
-
# 60–79 = Good opportunity — worth exploring
|
| 10 |
-
# 40–59 = Average — proceed with differentiation
|
| 11 |
-
# 20–39 = Difficult — needs strong USP
|
| 12 |
-
# 0–19 = Saturated / not viable
|
| 13 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 14 |
-
|
| 15 |
-
import math
|
| 16 |
-
from typing import Optional
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def calculate_niche_score(
|
| 20 |
-
bsr: Optional[int],
|
| 21 |
-
review_count: int,
|
| 22 |
-
rating: float,
|
| 23 |
-
price: float,
|
| 24 |
-
monthly_sales: int,
|
| 25 |
-
seller_count: int,
|
| 26 |
-
is_fba: bool,
|
| 27 |
-
category: str = "",
|
| 28 |
-
price_history: Optional[list] = None,
|
| 29 |
-
) -> dict:
|
| 30 |
-
"""
|
| 31 |
-
Calculate comprehensive niche opportunity score.
|
| 32 |
-
|
| 33 |
-
Five dimensions (each 0–100, weighted):
|
| 34 |
-
1. Demand strength (25%) — how much does the market want this?
|
| 35 |
-
2. Competition level (25%) — how hard is it to break in?
|
| 36 |
-
3. Profitability (20%) — how much money can be made?
|
| 37 |
-
4. Entry barrier (15%) — how hard is it to enter?
|
| 38 |
-
5. Market stability (15%) — is this a lasting opportunity?
|
| 39 |
-
"""
|
| 40 |
-
dimensions = {}
|
| 41 |
-
|
| 42 |
-
# ── 1. Demand Strength (25%) ────────────────────────────────────────────
|
| 43 |
-
demand_score = 0
|
| 44 |
-
demand_notes = []
|
| 45 |
-
|
| 46 |
-
if bsr and bsr > 0:
|
| 47 |
-
if bsr < 1_000: demand_score += 45; demand_notes.append("Elite BSR — massive demand")
|
| 48 |
-
elif bsr < 5_000: demand_score += 40; demand_notes.append("Excellent BSR — strong demand")
|
| 49 |
-
elif bsr < 10_000: demand_score += 35; demand_notes.append("Great BSR — good demand")
|
| 50 |
-
elif bsr < 25_000: demand_score += 28; demand_notes.append("Solid BSR — moderate-high demand")
|
| 51 |
-
elif bsr < 50_000: demand_score += 20; demand_notes.append("Decent BSR — moderate demand")
|
| 52 |
-
elif bsr < 100_000: demand_score += 12; demand_notes.append("Fair BSR — low-moderate demand")
|
| 53 |
-
else: demand_score += 4; demand_notes.append("High BSR — low demand")
|
| 54 |
-
|
| 55 |
-
if monthly_sales >= 1000: demand_score += 30; demand_notes.append("1,000+ units/month is excellent")
|
| 56 |
-
elif monthly_sales >= 500: demand_score += 25; demand_notes.append("500+ units/month is strong")
|
| 57 |
-
elif monthly_sales >= 200: demand_score += 18; demand_notes.append("200+ units/month is adequate")
|
| 58 |
-
elif monthly_sales >= 50: demand_score += 10; demand_notes.append("50+ units/month — marginal")
|
| 59 |
-
else: demand_score += 2
|
| 60 |
-
|
| 61 |
-
if review_count > 500: demand_score += 15; demand_notes.append("High review count confirms real demand")
|
| 62 |
-
elif review_count > 100: demand_score += 10
|
| 63 |
-
elif review_count > 20: demand_score += 5
|
| 64 |
-
|
| 65 |
-
demand_score = min(100, demand_score)
|
| 66 |
-
dimensions["demand"] = {
|
| 67 |
-
"score": demand_score,
|
| 68 |
-
"weight": 0.25,
|
| 69 |
-
"label": "Demand Strength",
|
| 70 |
-
"notes": demand_notes[:2],
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
# ── 2. Competition Level (25%) — INVERTED (low comp = high score) ────────
|
| 74 |
-
comp_score = 100 # start at 100 and deduct for each barrier
|
| 75 |
-
comp_notes = []
|
| 76 |
-
|
| 77 |
-
# Review count as barrier (harder to beat many reviews)
|
| 78 |
-
if review_count > 5000: comp_score -= 45; comp_notes.append(f"{review_count:,} reviews — very high barrier")
|
| 79 |
-
elif review_count > 2000: comp_score -= 35; comp_notes.append(f"{review_count:,} reviews — high barrier")
|
| 80 |
-
elif review_count > 500: comp_score -= 20; comp_notes.append(f"{review_count:,} reviews — moderate barrier")
|
| 81 |
-
elif review_count > 100: comp_score -= 10; comp_notes.append(f"{review_count:,} reviews — low barrier")
|
| 82 |
-
else: comp_notes.append(f"Only {review_count} reviews — easy to compete")
|
| 83 |
-
|
| 84 |
-
# Seller count
|
| 85 |
-
if seller_count > 20: comp_score -= 25; comp_notes.append("Heavily contested market")
|
| 86 |
-
elif seller_count > 10: comp_score -= 15; comp_notes.append("Competitive market")
|
| 87 |
-
elif seller_count > 5: comp_score -= 8; comp_notes.append("Moderate competition")
|
| 88 |
-
else: comp_notes.append(f"Only {seller_count} sellers — low competition")
|
| 89 |
-
|
| 90 |
-
# Rating raises bar
|
| 91 |
-
if rating >= 4.7: comp_score -= 10; comp_notes.append("Existing sellers have excellent ratings")
|
| 92 |
-
elif rating < 4.0 and rating > 0:
|
| 93 |
-
comp_score += 10; comp_notes.append("Poor existing ratings — easy to differentiate")
|
| 94 |
-
|
| 95 |
-
comp_score = max(0, min(100, comp_score))
|
| 96 |
-
dimensions["competition"] = {
|
| 97 |
-
"score": comp_score,
|
| 98 |
-
"weight": 0.25,
|
| 99 |
-
"label": "Competition Level",
|
| 100 |
-
"notes": comp_notes[:2],
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
# ── 3. Profitability (20%) ───────────────────────────────────────────────
|
| 104 |
-
profit_score = 0
|
| 105 |
-
profit_notes = []
|
| 106 |
-
|
| 107 |
-
# FBA fee structure: approx 15% referral + $3-5 FBA fee
|
| 108 |
-
if price > 0:
|
| 109 |
-
fba_fee = 3.5 + (price * 0.02) # simplified FBA estimate
|
| 110 |
-
referral_fee = price * 0.15
|
| 111 |
-
estimated_cogs = price * 0.30 # typical 30% COGS for FBA
|
| 112 |
-
estimated_profit = price - fba_fee - referral_fee - estimated_cogs
|
| 113 |
-
margin_pct = (estimated_profit / price * 100) if price > 0 else 0
|
| 114 |
-
|
| 115 |
-
if price >= 25: profit_score += 35; profit_notes.append(f"${price:.2f} price supports healthy margins")
|
| 116 |
-
elif price >= 15: profit_score += 25; profit_notes.append(f"${price:.2f} is workable for FBA")
|
| 117 |
-
elif price >= 10: profit_score += 15; profit_notes.append(f"${price:.2f} is tight margin territory")
|
| 118 |
-
else: profit_score += 5; profit_notes.append(f"${price:.2f} is too low for FBA profitability")
|
| 119 |
-
|
| 120 |
-
if margin_pct >= 30: profit_score += 40; profit_notes.append(f"Est. {margin_pct:.0f}% margin — excellent")
|
| 121 |
-
elif margin_pct >= 20: profit_score += 30; profit_notes.append(f"Est. {margin_pct:.0f}% margin — good")
|
| 122 |
-
elif margin_pct >= 10: profit_score += 18; profit_notes.append(f"Est. {margin_pct:.0f}% margin — acceptable")
|
| 123 |
-
else: profit_score += 5; profit_notes.append(f"Est. {margin_pct:.0f}% margin — poor")
|
| 124 |
-
|
| 125 |
-
# Revenue potential
|
| 126 |
-
monthly_revenue = monthly_sales * price if price > 0 and monthly_sales > 0 else 0
|
| 127 |
-
if monthly_revenue >= 10000: profit_score += 20
|
| 128 |
-
elif monthly_revenue >= 5000: profit_score += 15
|
| 129 |
-
elif monthly_revenue >= 2000: profit_score += 10
|
| 130 |
-
elif monthly_revenue >= 500: profit_score += 5
|
| 131 |
-
|
| 132 |
-
profit_score = min(100, profit_score)
|
| 133 |
-
dimensions["profitability"] = {
|
| 134 |
-
"score": profit_score,
|
| 135 |
-
"weight": 0.20,
|
| 136 |
-
"label": "Profitability",
|
| 137 |
-
"notes": profit_notes[:2],
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
# ── 4. Entry Barrier (15%) — INVERTED (low barrier = high score) ─────────
|
| 141 |
-
entry_score = 80 # start generous
|
| 142 |
-
entry_notes = []
|
| 143 |
-
|
| 144 |
-
# FBA sellers are harder to beat on logistics
|
| 145 |
-
if is_fba: entry_score -= 10; entry_notes.append("FBA competitors have logistics advantage")
|
| 146 |
-
|
| 147 |
-
# Patent / brand risk (proxy: established seller + high rating)
|
| 148 |
-
if review_count > 1000 and rating >= 4.6:
|
| 149 |
-
entry_score -= 20; entry_notes.append("Strong incumbent brand — needs differentiation")
|
| 150 |
-
elif review_count > 200:
|
| 151 |
-
entry_score -= 8
|
| 152 |
-
|
| 153 |
-
# Price point — low prices mean less room for error
|
| 154 |
-
if price < 15: entry_score -= 15; entry_notes.append("Low price point — thin margin for mistakes")
|
| 155 |
-
elif price > 50: entry_score += 10; entry_notes.append("Premium price point — room for quality differentiation")
|
| 156 |
-
|
| 157 |
-
if not entry_notes:
|
| 158 |
-
entry_notes.append("Low entry barriers — good opportunity for new seller")
|
| 159 |
-
|
| 160 |
-
entry_score = max(0, min(100, entry_score))
|
| 161 |
-
dimensions["entry_barrier"] = {
|
| 162 |
-
"score": entry_score,
|
| 163 |
-
"weight": 0.15,
|
| 164 |
-
"label": "Entry Barrier",
|
| 165 |
-
"notes": entry_notes[:2],
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
# ── 5. Market Stability (15%) ────────────────────────────────────────────
|
| 169 |
-
stability_score = 60 # default neutral
|
| 170 |
-
stability_notes = []
|
| 171 |
-
|
| 172 |
-
# Price stability (if history available)
|
| 173 |
-
if price_history and len(price_history) >= 3:
|
| 174 |
-
prices = [p["price"] for p in price_history if p.get("price") and p["price"] > 0]
|
| 175 |
-
if len(prices) >= 3:
|
| 176 |
-
avg_price = sum(prices) / len(prices)
|
| 177 |
-
variance = sum((p - avg_price) ** 2 for p in prices) / len(prices)
|
| 178 |
-
std_dev = math.sqrt(variance)
|
| 179 |
-
cv = std_dev / avg_price if avg_price > 0 else 0 # coefficient of variation
|
| 180 |
-
if cv < 0.05:
|
| 181 |
-
stability_score += 20; stability_notes.append("Very stable pricing history")
|
| 182 |
-
elif cv < 0.10:
|
| 183 |
-
stability_score += 10; stability_notes.append("Reasonably stable pricing")
|
| 184 |
-
elif cv > 0.20:
|
| 185 |
-
stability_score -= 15; stability_notes.append("High price volatility — risky market")
|
| 186 |
-
|
| 187 |
-
# Category stability proxy
|
| 188 |
-
stable_categories = ["home", "kitchen", "tools", "office", "sports", "garden"]
|
| 189 |
-
volatile_categories = ["electronics", "tech", "gaming", "trending"]
|
| 190 |
-
cat_lower = category.lower()
|
| 191 |
-
if any(c in cat_lower for c in stable_categories):
|
| 192 |
-
stability_score += 10; stability_notes.append("Evergreen category — consistent year-round demand")
|
| 193 |
-
elif any(c in cat_lower for c in volatile_categories):
|
| 194 |
-
stability_score -= 10; stability_notes.append("Fast-moving category — trends change quickly")
|
| 195 |
-
|
| 196 |
-
if not stability_notes:
|
| 197 |
-
stability_notes.append("Market stability appears adequate for FBA business")
|
| 198 |
-
|
| 199 |
-
stability_score = max(0, min(100, stability_score))
|
| 200 |
-
dimensions["stability"] = {
|
| 201 |
-
"score": stability_score,
|
| 202 |
-
"weight": 0.15,
|
| 203 |
-
"label": "Market Stability",
|
| 204 |
-
"notes": stability_notes[:2],
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
# ── Weighted composite score ─────────────────────────────────────────────
|
| 208 |
-
niche_score = int(sum(d["score"] * d["weight"] for d in dimensions.values()))
|
| 209 |
-
|
| 210 |
-
# ── Grade and verdict ───────────────────────────────────────────────────
|
| 211 |
-
if niche_score >= 80:
|
| 212 |
-
grade = "A"
|
| 213 |
-
verdict = "Excellent niche opportunity"
|
| 214 |
-
action = "Enter now — strong demand, manageable competition, good margins"
|
| 215 |
-
color = "green"
|
| 216 |
-
elif niche_score >= 65:
|
| 217 |
-
grade = "B"
|
| 218 |
-
verdict = "Good opportunity worth pursuing"
|
| 219 |
-
action = "Proceed with a differentiated product — find a gap in existing listings"
|
| 220 |
-
color = "teal"
|
| 221 |
-
elif niche_score >= 50:
|
| 222 |
-
grade = "C"
|
| 223 |
-
verdict = "Average opportunity — needs careful execution"
|
| 224 |
-
action = "Only enter with a clear USP (unique design, bundle, or brand story)"
|
| 225 |
-
color = "amber"
|
| 226 |
-
elif niche_score >= 35:
|
| 227 |
-
grade = "D"
|
| 228 |
-
verdict = "Difficult niche — high risk"
|
| 229 |
-
action = "High competition and low margins make this risky. Look for adjacent niches."
|
| 230 |
-
color = "orange"
|
| 231 |
-
else:
|
| 232 |
-
grade = "F"
|
| 233 |
-
verdict = "Not recommended — saturated or unviable"
|
| 234 |
-
action = "Avoid this product. Move on to a different niche."
|
| 235 |
-
color = "red"
|
| 236 |
-
|
| 237 |
-
result = {
|
| 238 |
-
"niche_score": niche_score,
|
| 239 |
-
"grade": grade,
|
| 240 |
-
"verdict": verdict,
|
| 241 |
-
"action": action,
|
| 242 |
-
"color": color,
|
| 243 |
-
"dimensions": dimensions,
|
| 244 |
-
"engine": "heuristic",
|
| 245 |
-
"model": "weighted-composite",
|
| 246 |
-
"summary": {
|
| 247 |
-
"demand_score": dimensions["demand"]["score"],
|
| 248 |
-
"competition_score": dimensions["competition"]["score"],
|
| 249 |
-
"profit_score": dimensions["profitability"]["score"],
|
| 250 |
-
"entry_score": dimensions["entry_barrier"]["score"],
|
| 251 |
-
"stability_score": dimensions["stability"]["score"],
|
| 252 |
-
},
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
try:
|
| 256 |
-
from app.services.ml.sklearn_engine import get_engine
|
| 257 |
-
engine = get_engine()
|
| 258 |
-
if engine:
|
| 259 |
-
ml = engine.predict_niche_score(
|
| 260 |
-
bsr, review_count, rating, price, monthly_sales, seller_count, is_fba
|
| 261 |
-
)
|
| 262 |
-
blended = int(min(100, max(0, ml["niche_score"] * 0.55 + niche_score * 0.45)))
|
| 263 |
-
result["niche_score"] = blended
|
| 264 |
-
result["grade"] = ml["grade"]
|
| 265 |
-
result["verdict"] = ml["verdict"]
|
| 266 |
-
result["action"] = ml["action"]
|
| 267 |
-
result["engine"] = "sklearn+heuristic"
|
| 268 |
-
result["model"] = ml.get("model", "GradientBoostingRegressor")
|
| 269 |
-
result["model_version"] = ml.get("model_version")
|
| 270 |
-
if blended >= 80:
|
| 271 |
-
result["color"] = "green"
|
| 272 |
-
elif blended >= 65:
|
| 273 |
-
result["color"] = "teal"
|
| 274 |
-
elif blended >= 50:
|
| 275 |
-
result["color"] = "amber"
|
| 276 |
-
elif blended >= 35:
|
| 277 |
-
result["color"] = "orange"
|
| 278 |
-
else:
|
| 279 |
-
result["color"] = "red"
|
| 280 |
-
except Exception as e:
|
| 281 |
-
print(f"[niche_scorer] sklearn blend failed: {e}")
|
| 282 |
-
|
| 283 |
return result
|
|
|
|
| 1 |
+
# app/services/ml/niche_scorer.py
|
| 2 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# Niche Score — composite scoring of market opportunity.
|
| 4 |
+
# Combines demand strength, competition difficulty, profitability potential,
|
| 5 |
+
# entry barrier, and market trend into a single actionable score.
|
| 6 |
+
#
|
| 7 |
+
# Score: 0–100 where:
|
| 8 |
+
# 80–100 = Excellent niche — enter now
|
| 9 |
+
# 60–79 = Good opportunity — worth exploring
|
| 10 |
+
# 40–59 = Average — proceed with differentiation
|
| 11 |
+
# 20–39 = Difficult — needs strong USP
|
| 12 |
+
# 0–19 = Saturated / not viable
|
| 13 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def calculate_niche_score(
|
| 20 |
+
bsr: Optional[int],
|
| 21 |
+
review_count: int,
|
| 22 |
+
rating: float,
|
| 23 |
+
price: float,
|
| 24 |
+
monthly_sales: int,
|
| 25 |
+
seller_count: int,
|
| 26 |
+
is_fba: bool,
|
| 27 |
+
category: str = "",
|
| 28 |
+
price_history: Optional[list] = None,
|
| 29 |
+
) -> dict:
|
| 30 |
+
"""
|
| 31 |
+
Calculate comprehensive niche opportunity score.
|
| 32 |
+
|
| 33 |
+
Five dimensions (each 0–100, weighted):
|
| 34 |
+
1. Demand strength (25%) — how much does the market want this?
|
| 35 |
+
2. Competition level (25%) — how hard is it to break in?
|
| 36 |
+
3. Profitability (20%) — how much money can be made?
|
| 37 |
+
4. Entry barrier (15%) — how hard is it to enter?
|
| 38 |
+
5. Market stability (15%) — is this a lasting opportunity?
|
| 39 |
+
"""
|
| 40 |
+
dimensions = {}
|
| 41 |
+
|
| 42 |
+
# ── 1. Demand Strength (25%) ────────────────────────────────────────────
|
| 43 |
+
demand_score = 0
|
| 44 |
+
demand_notes = []
|
| 45 |
+
|
| 46 |
+
if bsr and bsr > 0:
|
| 47 |
+
if bsr < 1_000: demand_score += 45; demand_notes.append("Elite BSR — massive demand")
|
| 48 |
+
elif bsr < 5_000: demand_score += 40; demand_notes.append("Excellent BSR — strong demand")
|
| 49 |
+
elif bsr < 10_000: demand_score += 35; demand_notes.append("Great BSR — good demand")
|
| 50 |
+
elif bsr < 25_000: demand_score += 28; demand_notes.append("Solid BSR — moderate-high demand")
|
| 51 |
+
elif bsr < 50_000: demand_score += 20; demand_notes.append("Decent BSR — moderate demand")
|
| 52 |
+
elif bsr < 100_000: demand_score += 12; demand_notes.append("Fair BSR — low-moderate demand")
|
| 53 |
+
else: demand_score += 4; demand_notes.append("High BSR — low demand")
|
| 54 |
+
|
| 55 |
+
if monthly_sales >= 1000: demand_score += 30; demand_notes.append("1,000+ units/month is excellent")
|
| 56 |
+
elif monthly_sales >= 500: demand_score += 25; demand_notes.append("500+ units/month is strong")
|
| 57 |
+
elif monthly_sales >= 200: demand_score += 18; demand_notes.append("200+ units/month is adequate")
|
| 58 |
+
elif monthly_sales >= 50: demand_score += 10; demand_notes.append("50+ units/month — marginal")
|
| 59 |
+
else: demand_score += 2
|
| 60 |
+
|
| 61 |
+
if review_count > 500: demand_score += 15; demand_notes.append("High review count confirms real demand")
|
| 62 |
+
elif review_count > 100: demand_score += 10
|
| 63 |
+
elif review_count > 20: demand_score += 5
|
| 64 |
+
|
| 65 |
+
demand_score = min(100, demand_score)
|
| 66 |
+
dimensions["demand"] = {
|
| 67 |
+
"score": demand_score,
|
| 68 |
+
"weight": 0.25,
|
| 69 |
+
"label": "Demand Strength",
|
| 70 |
+
"notes": demand_notes[:2],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
# ── 2. Competition Level (25%) — INVERTED (low comp = high score) ────────
|
| 74 |
+
comp_score = 100 # start at 100 and deduct for each barrier
|
| 75 |
+
comp_notes = []
|
| 76 |
+
|
| 77 |
+
# Review count as barrier (harder to beat many reviews)
|
| 78 |
+
if review_count > 5000: comp_score -= 45; comp_notes.append(f"{review_count:,} reviews — very high barrier")
|
| 79 |
+
elif review_count > 2000: comp_score -= 35; comp_notes.append(f"{review_count:,} reviews — high barrier")
|
| 80 |
+
elif review_count > 500: comp_score -= 20; comp_notes.append(f"{review_count:,} reviews — moderate barrier")
|
| 81 |
+
elif review_count > 100: comp_score -= 10; comp_notes.append(f"{review_count:,} reviews — low barrier")
|
| 82 |
+
else: comp_notes.append(f"Only {review_count} reviews — easy to compete")
|
| 83 |
+
|
| 84 |
+
# Seller count
|
| 85 |
+
if seller_count > 20: comp_score -= 25; comp_notes.append("Heavily contested market")
|
| 86 |
+
elif seller_count > 10: comp_score -= 15; comp_notes.append("Competitive market")
|
| 87 |
+
elif seller_count > 5: comp_score -= 8; comp_notes.append("Moderate competition")
|
| 88 |
+
else: comp_notes.append(f"Only {seller_count} sellers — low competition")
|
| 89 |
+
|
| 90 |
+
# Rating raises bar
|
| 91 |
+
if rating >= 4.7: comp_score -= 10; comp_notes.append("Existing sellers have excellent ratings")
|
| 92 |
+
elif rating < 4.0 and rating > 0:
|
| 93 |
+
comp_score += 10; comp_notes.append("Poor existing ratings — easy to differentiate")
|
| 94 |
+
|
| 95 |
+
comp_score = max(0, min(100, comp_score))
|
| 96 |
+
dimensions["competition"] = {
|
| 97 |
+
"score": comp_score,
|
| 98 |
+
"weight": 0.25,
|
| 99 |
+
"label": "Competition Level",
|
| 100 |
+
"notes": comp_notes[:2],
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# ── 3. Profitability (20%) ───────────────────────────────────────────────
|
| 104 |
+
profit_score = 0
|
| 105 |
+
profit_notes = []
|
| 106 |
+
|
| 107 |
+
# FBA fee structure: approx 15% referral + $3-5 FBA fee
|
| 108 |
+
if price > 0:
|
| 109 |
+
fba_fee = 3.5 + (price * 0.02) # simplified FBA estimate
|
| 110 |
+
referral_fee = price * 0.15
|
| 111 |
+
estimated_cogs = price * 0.30 # typical 30% COGS for FBA
|
| 112 |
+
estimated_profit = price - fba_fee - referral_fee - estimated_cogs
|
| 113 |
+
margin_pct = (estimated_profit / price * 100) if price > 0 else 0
|
| 114 |
+
|
| 115 |
+
if price >= 25: profit_score += 35; profit_notes.append(f"${price:.2f} price supports healthy margins")
|
| 116 |
+
elif price >= 15: profit_score += 25; profit_notes.append(f"${price:.2f} is workable for FBA")
|
| 117 |
+
elif price >= 10: profit_score += 15; profit_notes.append(f"${price:.2f} is tight margin territory")
|
| 118 |
+
else: profit_score += 5; profit_notes.append(f"${price:.2f} is too low for FBA profitability")
|
| 119 |
+
|
| 120 |
+
if margin_pct >= 30: profit_score += 40; profit_notes.append(f"Est. {margin_pct:.0f}% margin — excellent")
|
| 121 |
+
elif margin_pct >= 20: profit_score += 30; profit_notes.append(f"Est. {margin_pct:.0f}% margin — good")
|
| 122 |
+
elif margin_pct >= 10: profit_score += 18; profit_notes.append(f"Est. {margin_pct:.0f}% margin — acceptable")
|
| 123 |
+
else: profit_score += 5; profit_notes.append(f"Est. {margin_pct:.0f}% margin — poor")
|
| 124 |
+
|
| 125 |
+
# Revenue potential
|
| 126 |
+
monthly_revenue = monthly_sales * price if price > 0 and monthly_sales > 0 else 0
|
| 127 |
+
if monthly_revenue >= 10000: profit_score += 20
|
| 128 |
+
elif monthly_revenue >= 5000: profit_score += 15
|
| 129 |
+
elif monthly_revenue >= 2000: profit_score += 10
|
| 130 |
+
elif monthly_revenue >= 500: profit_score += 5
|
| 131 |
+
|
| 132 |
+
profit_score = min(100, profit_score)
|
| 133 |
+
dimensions["profitability"] = {
|
| 134 |
+
"score": profit_score,
|
| 135 |
+
"weight": 0.20,
|
| 136 |
+
"label": "Profitability",
|
| 137 |
+
"notes": profit_notes[:2],
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
# ── 4. Entry Barrier (15%) — INVERTED (low barrier = high score) ─────────
|
| 141 |
+
entry_score = 80 # start generous
|
| 142 |
+
entry_notes = []
|
| 143 |
+
|
| 144 |
+
# FBA sellers are harder to beat on logistics
|
| 145 |
+
if is_fba: entry_score -= 10; entry_notes.append("FBA competitors have logistics advantage")
|
| 146 |
+
|
| 147 |
+
# Patent / brand risk (proxy: established seller + high rating)
|
| 148 |
+
if review_count > 1000 and rating >= 4.6:
|
| 149 |
+
entry_score -= 20; entry_notes.append("Strong incumbent brand — needs differentiation")
|
| 150 |
+
elif review_count > 200:
|
| 151 |
+
entry_score -= 8
|
| 152 |
+
|
| 153 |
+
# Price point — low prices mean less room for error
|
| 154 |
+
if price < 15: entry_score -= 15; entry_notes.append("Low price point — thin margin for mistakes")
|
| 155 |
+
elif price > 50: entry_score += 10; entry_notes.append("Premium price point — room for quality differentiation")
|
| 156 |
+
|
| 157 |
+
if not entry_notes:
|
| 158 |
+
entry_notes.append("Low entry barriers — good opportunity for new seller")
|
| 159 |
+
|
| 160 |
+
entry_score = max(0, min(100, entry_score))
|
| 161 |
+
dimensions["entry_barrier"] = {
|
| 162 |
+
"score": entry_score,
|
| 163 |
+
"weight": 0.15,
|
| 164 |
+
"label": "Entry Barrier",
|
| 165 |
+
"notes": entry_notes[:2],
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
# ── 5. Market Stability (15%) ────────────────────────────────────────────
|
| 169 |
+
stability_score = 60 # default neutral
|
| 170 |
+
stability_notes = []
|
| 171 |
+
|
| 172 |
+
# Price stability (if history available)
|
| 173 |
+
if price_history and len(price_history) >= 3:
|
| 174 |
+
prices = [p["price"] for p in price_history if p.get("price") and p["price"] > 0]
|
| 175 |
+
if len(prices) >= 3:
|
| 176 |
+
avg_price = sum(prices) / len(prices)
|
| 177 |
+
variance = sum((p - avg_price) ** 2 for p in prices) / len(prices)
|
| 178 |
+
std_dev = math.sqrt(variance)
|
| 179 |
+
cv = std_dev / avg_price if avg_price > 0 else 0 # coefficient of variation
|
| 180 |
+
if cv < 0.05:
|
| 181 |
+
stability_score += 20; stability_notes.append("Very stable pricing history")
|
| 182 |
+
elif cv < 0.10:
|
| 183 |
+
stability_score += 10; stability_notes.append("Reasonably stable pricing")
|
| 184 |
+
elif cv > 0.20:
|
| 185 |
+
stability_score -= 15; stability_notes.append("High price volatility — risky market")
|
| 186 |
+
|
| 187 |
+
# Category stability proxy
|
| 188 |
+
stable_categories = ["home", "kitchen", "tools", "office", "sports", "garden"]
|
| 189 |
+
volatile_categories = ["electronics", "tech", "gaming", "trending"]
|
| 190 |
+
cat_lower = category.lower()
|
| 191 |
+
if any(c in cat_lower for c in stable_categories):
|
| 192 |
+
stability_score += 10; stability_notes.append("Evergreen category — consistent year-round demand")
|
| 193 |
+
elif any(c in cat_lower for c in volatile_categories):
|
| 194 |
+
stability_score -= 10; stability_notes.append("Fast-moving category — trends change quickly")
|
| 195 |
+
|
| 196 |
+
if not stability_notes:
|
| 197 |
+
stability_notes.append("Market stability appears adequate for FBA business")
|
| 198 |
+
|
| 199 |
+
stability_score = max(0, min(100, stability_score))
|
| 200 |
+
dimensions["stability"] = {
|
| 201 |
+
"score": stability_score,
|
| 202 |
+
"weight": 0.15,
|
| 203 |
+
"label": "Market Stability",
|
| 204 |
+
"notes": stability_notes[:2],
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
# ── Weighted composite score ─────────────────────────────────────────────
|
| 208 |
+
niche_score = int(sum(d["score"] * d["weight"] for d in dimensions.values()))
|
| 209 |
+
|
| 210 |
+
# ── Grade and verdict ───────────────────────────���────────────────────────
|
| 211 |
+
if niche_score >= 80:
|
| 212 |
+
grade = "A"
|
| 213 |
+
verdict = "Excellent niche opportunity"
|
| 214 |
+
action = "Enter now — strong demand, manageable competition, good margins"
|
| 215 |
+
color = "green"
|
| 216 |
+
elif niche_score >= 65:
|
| 217 |
+
grade = "B"
|
| 218 |
+
verdict = "Good opportunity worth pursuing"
|
| 219 |
+
action = "Proceed with a differentiated product — find a gap in existing listings"
|
| 220 |
+
color = "teal"
|
| 221 |
+
elif niche_score >= 50:
|
| 222 |
+
grade = "C"
|
| 223 |
+
verdict = "Average opportunity — needs careful execution"
|
| 224 |
+
action = "Only enter with a clear USP (unique design, bundle, or brand story)"
|
| 225 |
+
color = "amber"
|
| 226 |
+
elif niche_score >= 35:
|
| 227 |
+
grade = "D"
|
| 228 |
+
verdict = "Difficult niche — high risk"
|
| 229 |
+
action = "High competition and low margins make this risky. Look for adjacent niches."
|
| 230 |
+
color = "orange"
|
| 231 |
+
else:
|
| 232 |
+
grade = "F"
|
| 233 |
+
verdict = "Not recommended — saturated or unviable"
|
| 234 |
+
action = "Avoid this product. Move on to a different niche."
|
| 235 |
+
color = "red"
|
| 236 |
+
|
| 237 |
+
result = {
|
| 238 |
+
"niche_score": niche_score,
|
| 239 |
+
"grade": grade,
|
| 240 |
+
"verdict": verdict,
|
| 241 |
+
"action": action,
|
| 242 |
+
"color": color,
|
| 243 |
+
"dimensions": dimensions,
|
| 244 |
+
"engine": "heuristic",
|
| 245 |
+
"model": "weighted-composite",
|
| 246 |
+
"summary": {
|
| 247 |
+
"demand_score": dimensions["demand"]["score"],
|
| 248 |
+
"competition_score": dimensions["competition"]["score"],
|
| 249 |
+
"profit_score": dimensions["profitability"]["score"],
|
| 250 |
+
"entry_score": dimensions["entry_barrier"]["score"],
|
| 251 |
+
"stability_score": dimensions["stability"]["score"],
|
| 252 |
+
},
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
try:
|
| 256 |
+
from app.services.ml.sklearn_engine import get_engine
|
| 257 |
+
engine = get_engine()
|
| 258 |
+
if engine:
|
| 259 |
+
ml = engine.predict_niche_score(
|
| 260 |
+
bsr, review_count, rating, price, monthly_sales, seller_count, is_fba
|
| 261 |
+
)
|
| 262 |
+
blended = int(min(100, max(0, ml["niche_score"] * 0.55 + niche_score * 0.45)))
|
| 263 |
+
result["niche_score"] = blended
|
| 264 |
+
result["grade"] = ml["grade"]
|
| 265 |
+
result["verdict"] = ml["verdict"]
|
| 266 |
+
result["action"] = ml["action"]
|
| 267 |
+
result["engine"] = "sklearn+heuristic"
|
| 268 |
+
result["model"] = ml.get("model", "GradientBoostingRegressor")
|
| 269 |
+
result["model_version"] = ml.get("model_version")
|
| 270 |
+
if blended >= 80:
|
| 271 |
+
result["color"] = "green"
|
| 272 |
+
elif blended >= 65:
|
| 273 |
+
result["color"] = "teal"
|
| 274 |
+
elif blended >= 50:
|
| 275 |
+
result["color"] = "amber"
|
| 276 |
+
elif blended >= 35:
|
| 277 |
+
result["color"] = "orange"
|
| 278 |
+
else:
|
| 279 |
+
result["color"] = "red"
|
| 280 |
+
except Exception as e:
|
| 281 |
+
print(f"[niche_scorer] sklearn blend failed: {e}")
|
| 282 |
+
|
| 283 |
return result
|
app/services/ml/price_predictor.py
CHANGED
|
@@ -1,240 +1,240 @@
|
|
| 1 |
-
# app/services/ml/price_predictor.py
|
| 2 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
-
# Price Prediction using linear regression on BSR-to-price relationship.
|
| 4 |
-
#
|
| 5 |
-
# Academic basis: Simple linear regression (ordinary least squares).
|
| 6 |
-
# We use the well-documented inverse relationship between Amazon BSR and
|
| 7 |
-
# optimal pricing to predict where prices are likely to move.
|
| 8 |
-
#
|
| 9 |
-
# Model inputs: current_price, bsr, rating, review_count, seller_count
|
| 10 |
-
# Outputs: predicted price in 30/60/90 days + confidence interval
|
| 11 |
-
# ─────────────────────────────────────────────────────────────────────────────
|
| 12 |
-
|
| 13 |
-
import math
|
| 14 |
-
from typing import Optional
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
# Amazon category average prices (empirical from seller data)
|
| 18 |
-
_CATEGORY_BASELINES = {
|
| 19 |
-
"electronics": 45.0,
|
| 20 |
-
"home & kitchen": 25.0,
|
| 21 |
-
"clothing": 22.0,
|
| 22 |
-
"books": 14.0,
|
| 23 |
-
"toys & games": 20.0,
|
| 24 |
-
"sports": 28.0,
|
| 25 |
-
"health": 18.0,
|
| 26 |
-
"beauty": 16.0,
|
| 27 |
-
"tools": 32.0,
|
| 28 |
-
"grocery": 12.0,
|
| 29 |
-
"default": 24.0,
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def predict_price(
|
| 34 |
-
current_price: float,
|
| 35 |
-
bsr: Optional[int],
|
| 36 |
-
rating: float,
|
| 37 |
-
review_count: int,
|
| 38 |
-
seller_count: int = 1,
|
| 39 |
-
category: str = "",
|
| 40 |
-
price_history: Optional[list] = None,
|
| 41 |
-
) -> dict:
|
| 42 |
-
"""
|
| 43 |
-
Predict future price movements for an Amazon product.
|
| 44 |
-
|
| 45 |
-
Uses a regression model combining:
|
| 46 |
-
- BSR pressure coefficient (high BSR → price pressure downward)
|
| 47 |
-
- Competition coefficient (more sellers → price drops)
|
| 48 |
-
- Review momentum (growing reviews → can sustain higher price)
|
| 49 |
-
- Historical trend (if price_history available)
|
| 50 |
-
|
| 51 |
-
Returns predictions for 30, 60, 90 days with confidence intervals.
|
| 52 |
-
"""
|
| 53 |
-
if current_price <= 0:
|
| 54 |
-
return _empty_prediction(current_price)
|
| 55 |
-
|
| 56 |
-
# ── Category baseline ────────────────────────────────────────────────────
|
| 57 |
-
cat_key = category.lower() if category else "default"
|
| 58 |
-
baseline = next(
|
| 59 |
-
(v for k, v in _CATEGORY_BASELINES.items() if k in cat_key),
|
| 60 |
-
_CATEGORY_BASELINES["default"]
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
# ── BSR pressure coefficient ─────────────────────────────────────────────
|
| 64 |
-
# High BSR (poor rank) = product is losing — price pressure downward
|
| 65 |
-
# Low BSR (good rank) = product is winning — can sustain or raise price
|
| 66 |
-
if bsr and bsr > 0:
|
| 67 |
-
# Normalise BSR: rank 1 = 1.0, rank 1,000,000 = 0.0
|
| 68 |
-
bsr_norm = max(0.0, 1.0 - math.log10(bsr) / 6.0) # log10(1M) = 6
|
| 69 |
-
bsr_coeff = (bsr_norm - 0.5) * 0.08 # -4% to +4% per month pressure
|
| 70 |
-
else:
|
| 71 |
-
bsr_norm = 0.5
|
| 72 |
-
bsr_coeff = 0.0
|
| 73 |
-
|
| 74 |
-
# ── Competition coefficient ──────────────────────────────────────────────
|
| 75 |
-
# More sellers = race to bottom = price drops ~1-2% per extra seller
|
| 76 |
-
competition_coeff = -0.012 * max(0, seller_count - 1) # -1.2% per extra seller
|
| 77 |
-
|
| 78 |
-
# ── Review momentum ──────────────────────────────────────────────────────
|
| 79 |
-
# Products gaining reviews can hold or slightly raise prices
|
| 80 |
-
# High rating + many reviews = pricing power
|
| 81 |
-
review_norm = min(1.0, math.log10(max(1, review_count)) / 4.0) # 0–1
|
| 82 |
-
rating_power = (rating - 3.0) / 2.0 if rating > 0 else 0 # -1.5 to +1
|
| 83 |
-
review_coeff = review_norm * rating_power * 0.015 # up to +1.5% / month
|
| 84 |
-
|
| 85 |
-
# ── Historical trend (if available) ──────────────────────────────────────
|
| 86 |
-
historical_monthly_change = 0.0
|
| 87 |
-
trend_confidence_bonus = 0
|
| 88 |
-
if price_history and len(price_history) >= 3:
|
| 89 |
-
prices = [p["price"] for p in price_history if p.get("price")]
|
| 90 |
-
if len(prices) >= 3:
|
| 91 |
-
# Simple linear regression slope
|
| 92 |
-
n = len(prices)
|
| 93 |
-
x_mean = (n - 1) / 2
|
| 94 |
-
y_mean = sum(prices) / n
|
| 95 |
-
numerator = sum((i - x_mean) * (prices[i] - y_mean) for i in range(n))
|
| 96 |
-
denominator = sum((i - x_mean) ** 2 for i in range(n))
|
| 97 |
-
if denominator > 0:
|
| 98 |
-
slope = numerator / denominator # price change per period
|
| 99 |
-
# Convert to monthly % change
|
| 100 |
-
historical_monthly_change = slope / prices[0] if prices[0] > 0 else 0
|
| 101 |
-
trend_confidence_bonus = 15 # more confident with real data
|
| 102 |
-
|
| 103 |
-
# ── Combine coefficients ─────────────────────────────────────────────────
|
| 104 |
-
if historical_monthly_change != 0:
|
| 105 |
-
# Weight historical trend 60%, model 40%
|
| 106 |
-
model_monthly = bsr_coeff + competition_coeff + review_coeff
|
| 107 |
-
monthly_change_rate = 0.6 * historical_monthly_change + 0.4 * model_monthly
|
| 108 |
-
else:
|
| 109 |
-
monthly_change_rate = bsr_coeff + competition_coeff + review_coeff
|
| 110 |
-
|
| 111 |
-
# Cap monthly change: Amazon prices rarely move more than ±8% per month
|
| 112 |
-
monthly_change_rate = max(-0.08, min(0.08, monthly_change_rate))
|
| 113 |
-
|
| 114 |
-
# ── Predictions ──────────────────────────────────────────────────────────
|
| 115 |
-
p30 = round(current_price * (1 + monthly_change_rate), 2)
|
| 116 |
-
p60 = round(current_price * (1 + monthly_change_rate * 2), 2)
|
| 117 |
-
p90 = round(current_price * (1 + monthly_change_rate * 3), 2)
|
| 118 |
-
|
| 119 |
-
# Ensure prices stay positive
|
| 120 |
-
p30 = max(0.99, p30)
|
| 121 |
-
p60 = max(0.99, p60)
|
| 122 |
-
p90 = max(0.99, p90)
|
| 123 |
-
|
| 124 |
-
# ── Confidence interval (±%) ──────────────────────────────────────────────
|
| 125 |
-
# Less data → wider interval
|
| 126 |
-
base_uncertainty = 0.08 # ±8% base
|
| 127 |
-
if bsr: base_uncertainty -= 0.02
|
| 128 |
-
if review_count > 100: base_uncertainty -= 0.01
|
| 129 |
-
if price_history and len(price_history) >= 5: base_uncertainty -= 0.02
|
| 130 |
-
uncertainty = max(0.03, base_uncertainty)
|
| 131 |
-
|
| 132 |
-
# ── Confidence score ──────────────────────────────────────────────────────
|
| 133 |
-
confidence = 55 + trend_confidence_bonus
|
| 134 |
-
if bsr and bsr > 0: confidence += 10
|
| 135 |
-
if review_count > 50: confidence += 8
|
| 136 |
-
if price_history and len(price_history) >= 3: confidence += 12
|
| 137 |
-
confidence = min(92, confidence)
|
| 138 |
-
|
| 139 |
-
# ── Direction label ───────────────────────────────────────────────────────
|
| 140 |
-
if monthly_change_rate > 0.02:
|
| 141 |
-
direction = "upward"
|
| 142 |
-
direction_label = "Price likely to rise"
|
| 143 |
-
direction_reason = _build_reason(bsr_coeff, competition_coeff, review_coeff, "up")
|
| 144 |
-
elif monthly_change_rate < -0.02:
|
| 145 |
-
direction = "downward"
|
| 146 |
-
direction_label = "Price likely to decrease"
|
| 147 |
-
direction_reason = _build_reason(bsr_coeff, competition_coeff, review_coeff, "down")
|
| 148 |
-
else:
|
| 149 |
-
direction = "stable"
|
| 150 |
-
direction_label = "Price likely to remain stable"
|
| 151 |
-
direction_reason = "Competing forces are balanced — no strong movement expected"
|
| 152 |
-
|
| 153 |
-
# ── Pricing recommendation ────────────────────────────────────────────────
|
| 154 |
-
if direction == "downward" and abs(monthly_change_rate) > 0.04:
|
| 155 |
-
recommendation = f"Consider sourcing now before price drops further. Target entry price: ${p90:.2f}"
|
| 156 |
-
elif direction == "upward":
|
| 157 |
-
recommendation = f"Price trending up — list sooner. Predicted ceiling: ${p90:.2f}"
|
| 158 |
-
else:
|
| 159 |
-
recommendation = f"Stable pricing environment. Current price ${current_price:.2f} is representative of near-term market."
|
| 160 |
-
|
| 161 |
-
result = {
|
| 162 |
-
"current_price": current_price,
|
| 163 |
-
"predictions": {
|
| 164 |
-
"days_30": {"price": p30, "change_pct": round((p30 - current_price) / current_price * 100, 1),
|
| 165 |
-
"low": round(p30 * (1 - uncertainty), 2), "high": round(p30 * (1 + uncertainty), 2)},
|
| 166 |
-
"days_60": {"price": p60, "change_pct": round((p60 - current_price) / current_price * 100, 1),
|
| 167 |
-
"low": round(p60 * (1 - uncertainty), 2), "high": round(p60 * (1 + uncertainty), 2)},
|
| 168 |
-
"days_90": {"price": p90, "change_pct": round((p90 - current_price) / current_price * 100, 1),
|
| 169 |
-
"low": round(p90 * (1 - uncertainty), 2), "high": round(p90 * (1 + uncertainty), 2)},
|
| 170 |
-
},
|
| 171 |
-
"direction": direction,
|
| 172 |
-
"direction_label": direction_label,
|
| 173 |
-
"direction_reason": direction_reason,
|
| 174 |
-
"monthly_change_pct": round(monthly_change_rate * 100, 2),
|
| 175 |
-
"confidence": confidence,
|
| 176 |
-
"recommendation": recommendation,
|
| 177 |
-
"engine": "heuristic",
|
| 178 |
-
"model": "linear-regression",
|
| 179 |
-
"model_factors": {
|
| 180 |
-
"bsr_pressure": round(bsr_coeff * 100, 2),
|
| 181 |
-
"competition_pressure": round(competition_coeff * 100, 2),
|
| 182 |
-
"review_momentum": round(review_coeff * 100, 2),
|
| 183 |
-
"historical_trend": round(historical_monthly_change * 100, 2) if historical_monthly_change else None,
|
| 184 |
-
},
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
try:
|
| 188 |
-
from app.services.ml.sklearn_engine import get_engine
|
| 189 |
-
engine = get_engine()
|
| 190 |
-
if engine:
|
| 191 |
-
ml = engine.predict_price_delta(current_price, bsr, rating, review_count, seller_count)
|
| 192 |
-
result["predictions"] = ml["predictions"]
|
| 193 |
-
result["monthly_change_pct"] = ml["monthly_change_pct"]
|
| 194 |
-
result["model_factors"] = {**result["model_factors"], **ml["model_factors"]}
|
| 195 |
-
result["confidence"] = max(confidence, ml.get("confidence", 70))
|
| 196 |
-
result["engine"] = "sklearn+heuristic"
|
| 197 |
-
result["model"] = ml.get("model", "GradientBoostingRegressor")
|
| 198 |
-
result["model_version"] = ml.get("model_version")
|
| 199 |
-
d = ml["direction"]
|
| 200 |
-
result["direction"] = "upward" if d == "up" else "downward" if d == "down" else "stable"
|
| 201 |
-
result["direction_label"] = ml["direction_label"]
|
| 202 |
-
result["direction_reason"] = (
|
| 203 |
-
f"Scikit-learn price model predicts {ml['monthly_change_pct']:+.1f}% over 30 days "
|
| 204 |
-
f"(blended with BSR/competition signals)."
|
| 205 |
-
)
|
| 206 |
-
except Exception as e:
|
| 207 |
-
print(f"[price_predictor] sklearn blend failed: {e}")
|
| 208 |
-
|
| 209 |
-
return result
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
def _build_reason(bsr_c, comp_c, rev_c, direction):
|
| 213 |
-
factors = []
|
| 214 |
-
if abs(bsr_c) > 0.01:
|
| 215 |
-
factors.append("BSR rank pressure" if bsr_c > 0 else "weak sales rank pushing price down")
|
| 216 |
-
if abs(comp_c) > 0.01:
|
| 217 |
-
factors.append(f"competition ({abs(comp_c)*100:.1f}% downward pressure)")
|
| 218 |
-
if abs(rev_c) > 0.005:
|
| 219 |
-
factors.append("strong review momentum supporting price" if rev_c > 0 else "declining review sentiment")
|
| 220 |
-
if not factors:
|
| 221 |
-
return "Marginal pressure from market dynamics"
|
| 222 |
-
return "Driven by: " + ", ".join(factors)
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
def _empty_prediction(price):
|
| 226 |
-
return {
|
| 227 |
-
"current_price": price,
|
| 228 |
-
"predictions": {
|
| 229 |
-
"days_30": {"price": price, "change_pct": 0.0, "low": price, "high": price},
|
| 230 |
-
"days_60": {"price": price, "change_pct": 0.0, "low": price, "high": price},
|
| 231 |
-
"days_90": {"price": price, "change_pct": 0.0, "low": price, "high": price},
|
| 232 |
-
},
|
| 233 |
-
"direction": "stable",
|
| 234 |
-
"direction_label": "Insufficient data",
|
| 235 |
-
"direction_reason": "Not enough data to make a prediction",
|
| 236 |
-
"monthly_change_pct": 0.0,
|
| 237 |
-
"confidence": 20,
|
| 238 |
-
"recommendation": "Gather more data by tracking this product over time.",
|
| 239 |
-
"model_factors": {"bsr_pressure": 0, "competition_pressure": 0, "review_momentum": 0, "historical_trend": None},
|
| 240 |
}
|
|
|
|
| 1 |
+
# app/services/ml/price_predictor.py
|
| 2 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 3 |
+
# Price Prediction using linear regression on BSR-to-price relationship.
|
| 4 |
+
#
|
| 5 |
+
# Academic basis: Simple linear regression (ordinary least squares).
|
| 6 |
+
# We use the well-documented inverse relationship between Amazon BSR and
|
| 7 |
+
# optimal pricing to predict where prices are likely to move.
|
| 8 |
+
#
|
| 9 |
+
# Model inputs: current_price, bsr, rating, review_count, seller_count
|
| 10 |
+
# Outputs: predicted price in 30/60/90 days + confidence interval
|
| 11 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Amazon category average prices (empirical from seller data)
|
| 18 |
+
_CATEGORY_BASELINES = {
|
| 19 |
+
"electronics": 45.0,
|
| 20 |
+
"home & kitchen": 25.0,
|
| 21 |
+
"clothing": 22.0,
|
| 22 |
+
"books": 14.0,
|
| 23 |
+
"toys & games": 20.0,
|
| 24 |
+
"sports": 28.0,
|
| 25 |
+
"health": 18.0,
|
| 26 |
+
"beauty": 16.0,
|
| 27 |
+
"tools": 32.0,
|
| 28 |
+
"grocery": 12.0,
|
| 29 |
+
"default": 24.0,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def predict_price(
|
| 34 |
+
current_price: float,
|
| 35 |
+
bsr: Optional[int],
|
| 36 |
+
rating: float,
|
| 37 |
+
review_count: int,
|
| 38 |
+
seller_count: int = 1,
|
| 39 |
+
category: str = "",
|
| 40 |
+
price_history: Optional[list] = None,
|
| 41 |
+
) -> dict:
|
| 42 |
+
"""
|
| 43 |
+
Predict future price movements for an Amazon product.
|
| 44 |
+
|
| 45 |
+
Uses a regression model combining:
|
| 46 |
+
- BSR pressure coefficient (high BSR → price pressure downward)
|
| 47 |
+
- Competition coefficient (more sellers → price drops)
|
| 48 |
+
- Review momentum (growing reviews → can sustain higher price)
|
| 49 |
+
- Historical trend (if price_history available)
|
| 50 |
+
|
| 51 |
+
Returns predictions for 30, 60, 90 days with confidence intervals.
|
| 52 |
+
"""
|
| 53 |
+
if current_price <= 0:
|
| 54 |
+
return _empty_prediction(current_price)
|
| 55 |
+
|
| 56 |
+
# ── Category baseline ────────────────────────────────────────────────────
|
| 57 |
+
cat_key = category.lower() if category else "default"
|
| 58 |
+
baseline = next(
|
| 59 |
+
(v for k, v in _CATEGORY_BASELINES.items() if k in cat_key),
|
| 60 |
+
_CATEGORY_BASELINES["default"]
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# ── BSR pressure coefficient ─────────────────────────────────────────────
|
| 64 |
+
# High BSR (poor rank) = product is losing — price pressure downward
|
| 65 |
+
# Low BSR (good rank) = product is winning — can sustain or raise price
|
| 66 |
+
if bsr and bsr > 0:
|
| 67 |
+
# Normalise BSR: rank 1 = 1.0, rank 1,000,000 = 0.0
|
| 68 |
+
bsr_norm = max(0.0, 1.0 - math.log10(bsr) / 6.0) # log10(1M) = 6
|
| 69 |
+
bsr_coeff = (bsr_norm - 0.5) * 0.08 # -4% to +4% per month pressure
|
| 70 |
+
else:
|
| 71 |
+
bsr_norm = 0.5
|
| 72 |
+
bsr_coeff = 0.0
|
| 73 |
+
|
| 74 |
+
# ── Competition coefficient ──────────────────────────────────────────────
|
| 75 |
+
# More sellers = race to bottom = price drops ~1-2% per extra seller
|
| 76 |
+
competition_coeff = -0.012 * max(0, seller_count - 1) # -1.2% per extra seller
|
| 77 |
+
|
| 78 |
+
# ── Review momentum ──────────────────────────────────────────────────────
|
| 79 |
+
# Products gaining reviews can hold or slightly raise prices
|
| 80 |
+
# High rating + many reviews = pricing power
|
| 81 |
+
review_norm = min(1.0, math.log10(max(1, review_count)) / 4.0) # 0–1
|
| 82 |
+
rating_power = (rating - 3.0) / 2.0 if rating > 0 else 0 # -1.5 to +1
|
| 83 |
+
review_coeff = review_norm * rating_power * 0.015 # up to +1.5% / month
|
| 84 |
+
|
| 85 |
+
# ── Historical trend (if available) ──────────────────────────────────────
|
| 86 |
+
historical_monthly_change = 0.0
|
| 87 |
+
trend_confidence_bonus = 0
|
| 88 |
+
if price_history and len(price_history) >= 3:
|
| 89 |
+
prices = [p["price"] for p in price_history if p.get("price")]
|
| 90 |
+
if len(prices) >= 3:
|
| 91 |
+
# Simple linear regression slope
|
| 92 |
+
n = len(prices)
|
| 93 |
+
x_mean = (n - 1) / 2
|
| 94 |
+
y_mean = sum(prices) / n
|
| 95 |
+
numerator = sum((i - x_mean) * (prices[i] - y_mean) for i in range(n))
|
| 96 |
+
denominator = sum((i - x_mean) ** 2 for i in range(n))
|
| 97 |
+
if denominator > 0:
|
| 98 |
+
slope = numerator / denominator # price change per period
|
| 99 |
+
# Convert to monthly % change
|
| 100 |
+
historical_monthly_change = slope / prices[0] if prices[0] > 0 else 0
|
| 101 |
+
trend_confidence_bonus = 15 # more confident with real data
|
| 102 |
+
|
| 103 |
+
# ── Combine coefficients ─────────────────────────────────────────────────
|
| 104 |
+
if historical_monthly_change != 0:
|
| 105 |
+
# Weight historical trend 60%, model 40%
|
| 106 |
+
model_monthly = bsr_coeff + competition_coeff + review_coeff
|
| 107 |
+
monthly_change_rate = 0.6 * historical_monthly_change + 0.4 * model_monthly
|
| 108 |
+
else:
|
| 109 |
+
monthly_change_rate = bsr_coeff + competition_coeff + review_coeff
|
| 110 |
+
|
| 111 |
+
# Cap monthly change: Amazon prices rarely move more than ±8% per month
|
| 112 |
+
monthly_change_rate = max(-0.08, min(0.08, monthly_change_rate))
|
| 113 |
+
|
| 114 |
+
# ── Predictions ──────────────────────────────────────────────────────────
|
| 115 |
+
p30 = round(current_price * (1 + monthly_change_rate), 2)
|
| 116 |
+
p60 = round(current_price * (1 + monthly_change_rate * 2), 2)
|
| 117 |
+
p90 = round(current_price * (1 + monthly_change_rate * 3), 2)
|
| 118 |
+
|
| 119 |
+
# Ensure prices stay positive
|
| 120 |
+
p30 = max(0.99, p30)
|
| 121 |
+
p60 = max(0.99, p60)
|
| 122 |
+
p90 = max(0.99, p90)
|
| 123 |
+
|
| 124 |
+
# ── Confidence interval (±%) ──────────────────────────────────────────────
|
| 125 |
+
# Less data → wider interval
|
| 126 |
+
base_uncertainty = 0.08 # ±8% base
|
| 127 |
+
if bsr: base_uncertainty -= 0.02
|
| 128 |
+
if review_count > 100: base_uncertainty -= 0.01
|
| 129 |
+
if price_history and len(price_history) >= 5: base_uncertainty -= 0.02
|
| 130 |
+
uncertainty = max(0.03, base_uncertainty)
|
| 131 |
+
|
| 132 |
+
# ── Confidence score ──────────────────────────────────────────────────────
|
| 133 |
+
confidence = 55 + trend_confidence_bonus
|
| 134 |
+
if bsr and bsr > 0: confidence += 10
|
| 135 |
+
if review_count > 50: confidence += 8
|
| 136 |
+
if price_history and len(price_history) >= 3: confidence += 12
|
| 137 |
+
confidence = min(92, confidence)
|
| 138 |
+
|
| 139 |
+
# ── Direction label ───────────────────────────────────────────────────────
|
| 140 |
+
if monthly_change_rate > 0.02:
|
| 141 |
+
direction = "upward"
|
| 142 |
+
direction_label = "Price likely to rise"
|
| 143 |
+
direction_reason = _build_reason(bsr_coeff, competition_coeff, review_coeff, "up")
|
| 144 |
+
elif monthly_change_rate < -0.02:
|
| 145 |
+
direction = "downward"
|
| 146 |
+
direction_label = "Price likely to decrease"
|
| 147 |
+
direction_reason = _build_reason(bsr_coeff, competition_coeff, review_coeff, "down")
|
| 148 |
+
else:
|
| 149 |
+
direction = "stable"
|
| 150 |
+
direction_label = "Price likely to remain stable"
|
| 151 |
+
direction_reason = "Competing forces are balanced — no strong movement expected"
|
| 152 |
+
|
| 153 |
+
# ── Pricing recommendation ────────────────────────────────────────────────
|
| 154 |
+
if direction == "downward" and abs(monthly_change_rate) > 0.04:
|
| 155 |
+
recommendation = f"Consider sourcing now before price drops further. Target entry price: ${p90:.2f}"
|
| 156 |
+
elif direction == "upward":
|
| 157 |
+
recommendation = f"Price trending up — list sooner. Predicted ceiling: ${p90:.2f}"
|
| 158 |
+
else:
|
| 159 |
+
recommendation = f"Stable pricing environment. Current price ${current_price:.2f} is representative of near-term market."
|
| 160 |
+
|
| 161 |
+
result = {
|
| 162 |
+
"current_price": current_price,
|
| 163 |
+
"predictions": {
|
| 164 |
+
"days_30": {"price": p30, "change_pct": round((p30 - current_price) / current_price * 100, 1),
|
| 165 |
+
"low": round(p30 * (1 - uncertainty), 2), "high": round(p30 * (1 + uncertainty), 2)},
|
| 166 |
+
"days_60": {"price": p60, "change_pct": round((p60 - current_price) / current_price * 100, 1),
|
| 167 |
+
"low": round(p60 * (1 - uncertainty), 2), "high": round(p60 * (1 + uncertainty), 2)},
|
| 168 |
+
"days_90": {"price": p90, "change_pct": round((p90 - current_price) / current_price * 100, 1),
|
| 169 |
+
"low": round(p90 * (1 - uncertainty), 2), "high": round(p90 * (1 + uncertainty), 2)},
|
| 170 |
+
},
|
| 171 |
+
"direction": direction,
|
| 172 |
+
"direction_label": direction_label,
|
| 173 |
+
"direction_reason": direction_reason,
|
| 174 |
+
"monthly_change_pct": round(monthly_change_rate * 100, 2),
|
| 175 |
+
"confidence": confidence,
|
| 176 |
+
"recommendation": recommendation,
|
| 177 |
+
"engine": "heuristic",
|
| 178 |
+
"model": "linear-regression",
|
| 179 |
+
"model_factors": {
|
| 180 |
+
"bsr_pressure": round(bsr_coeff * 100, 2),
|
| 181 |
+
"competition_pressure": round(competition_coeff * 100, 2),
|
| 182 |
+
"review_momentum": round(review_coeff * 100, 2),
|
| 183 |
+
"historical_trend": round(historical_monthly_change * 100, 2) if historical_monthly_change else None,
|
| 184 |
+
},
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
from app.services.ml.sklearn_engine import get_engine
|
| 189 |
+
engine = get_engine()
|
| 190 |
+
if engine:
|
| 191 |
+
ml = engine.predict_price_delta(current_price, bsr, rating, review_count, seller_count)
|
| 192 |
+
result["predictions"] = ml["predictions"]
|
| 193 |
+
result["monthly_change_pct"] = ml["monthly_change_pct"]
|
| 194 |
+
result["model_factors"] = {**result["model_factors"], **ml["model_factors"]}
|
| 195 |
+
result["confidence"] = max(confidence, ml.get("confidence", 70))
|
| 196 |
+
result["engine"] = "sklearn+heuristic"
|
| 197 |
+
result["model"] = ml.get("model", "GradientBoostingRegressor")
|
| 198 |
+
result["model_version"] = ml.get("model_version")
|
| 199 |
+
d = ml["direction"]
|
| 200 |
+
result["direction"] = "upward" if d == "up" else "downward" if d == "down" else "stable"
|
| 201 |
+
result["direction_label"] = ml["direction_label"]
|
| 202 |
+
result["direction_reason"] = (
|
| 203 |
+
f"Scikit-learn price model predicts {ml['monthly_change_pct']:+.1f}% over 30 days "
|
| 204 |
+
f"(blended with BSR/competition signals)."
|
| 205 |
+
)
|
| 206 |
+
except Exception as e:
|
| 207 |
+
print(f"[price_predictor] sklearn blend failed: {e}")
|
| 208 |
+
|
| 209 |
+
return result
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _build_reason(bsr_c, comp_c, rev_c, direction):
|
| 213 |
+
factors = []
|
| 214 |
+
if abs(bsr_c) > 0.01:
|
| 215 |
+
factors.append("BSR rank pressure" if bsr_c > 0 else "weak sales rank pushing price down")
|
| 216 |
+
if abs(comp_c) > 0.01:
|
| 217 |
+
factors.append(f"competition ({abs(comp_c)*100:.1f}% downward pressure)")
|
| 218 |
+
if abs(rev_c) > 0.005:
|
| 219 |
+
factors.append("strong review momentum supporting price" if rev_c > 0 else "declining review sentiment")
|
| 220 |
+
if not factors:
|
| 221 |
+
return "Marginal pressure from market dynamics"
|
| 222 |
+
return "Driven by: " + ", ".join(factors)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _empty_prediction(price):
|
| 226 |
+
return {
|
| 227 |
+
"current_price": price,
|
| 228 |
+
"predictions": {
|
| 229 |
+
"days_30": {"price": price, "change_pct": 0.0, "low": price, "high": price},
|
| 230 |
+
"days_60": {"price": price, "change_pct": 0.0, "low": price, "high": price},
|
| 231 |
+
"days_90": {"price": price, "change_pct": 0.0, "low": price, "high": price},
|
| 232 |
+
},
|
| 233 |
+
"direction": "stable",
|
| 234 |
+
"direction_label": "Insufficient data",
|
| 235 |
+
"direction_reason": "Not enough data to make a prediction",
|
| 236 |
+
"monthly_change_pct": 0.0,
|
| 237 |
+
"confidence": 20,
|
| 238 |
+
"recommendation": "Gather more data by tracking this product over time.",
|
| 239 |
+
"model_factors": {"bsr_pressure": 0, "competition_pressure": 0, "review_momentum": 0, "historical_trend": None},
|
| 240 |
}
|
app/services/ml/sklearn_engine.py
CHANGED
|
@@ -1,312 +1,312 @@
|
|
| 1 |
-
# app/services/ml/sklearn_engine.py
|
| 2 |
-
"""Scikit-learn ML engine for Rankora.
|
| 3 |
-
|
| 4 |
-
Trains lightweight models on a synthetic Amazon seller dataset at first use.
|
| 5 |
-
Models are real ML (RandomForest, GradientBoosting) — not pure heuristics.
|
| 6 |
-
Falls back gracefully if scikit-learn is unavailable.
|
| 7 |
-
"""
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import math
|
| 11 |
-
import random
|
| 12 |
-
from typing import Any, Dict, List, Optional, Tuple
|
| 13 |
-
|
| 14 |
-
_ENGINE: Optional["SklearnEngine"] = None
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def get_engine() -> Optional["SklearnEngine"]:
|
| 18 |
-
global _ENGINE
|
| 19 |
-
if _ENGINE is None:
|
| 20 |
-
try:
|
| 21 |
-
_ENGINE = SklearnEngine()
|
| 22 |
-
_ENGINE.train()
|
| 23 |
-
except Exception as e:
|
| 24 |
-
print(f"[sklearn_engine] init failed: {e}")
|
| 25 |
-
_ENGINE = None
|
| 26 |
-
return _ENGINE
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def is_sklearn_available() -> bool:
|
| 30 |
-
return get_engine() is not None
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class SklearnEngine:
|
| 34 |
-
"""Singleton trained models for product intelligence."""
|
| 35 |
-
|
| 36 |
-
MODEL_VERSION = "1.0.0"
|
| 37 |
-
|
| 38 |
-
def __init__(self) -> None:
|
| 39 |
-
self.fake_review_model = None
|
| 40 |
-
self.price_model = None
|
| 41 |
-
self.demand_model = None
|
| 42 |
-
self.niche_model = None
|
| 43 |
-
self._ready = False
|
| 44 |
-
|
| 45 |
-
def train(self) -> None:
|
| 46 |
-
import numpy as np
|
| 47 |
-
from sklearn.ensemble import GradientBoostingRegressor, RandomForestClassifier
|
| 48 |
-
|
| 49 |
-
rng = random.Random(42)
|
| 50 |
-
rows: List[Dict[str, float]] = []
|
| 51 |
-
|
| 52 |
-
for _ in range(2500):
|
| 53 |
-
bsr = rng.choice([rng.randint(50, 5000), rng.randint(5000, 80000), rng.randint(80000, 900000)])
|
| 54 |
-
rating = round(min(5.0, max(2.0, rng.gauss(4.2, 0.45))), 2)
|
| 55 |
-
review_count = int(max(0, rng.gauss(bsr / 15, bsr / 40)))
|
| 56 |
-
monthly_sales = max(1, int(8500 / math.log10(bsr + 10)))
|
| 57 |
-
seller_count = rng.randint(1, 12)
|
| 58 |
-
price = round(max(8.0, rng.gauss(28, 14)), 2)
|
| 59 |
-
is_fba = 1 if rng.random() > 0.25 else 0
|
| 60 |
-
|
| 61 |
-
review_ratio = review_count / max(monthly_sales * 12 * 0.04, 1)
|
| 62 |
-
bsr_norm = max(0.0, 1.0 - math.log10(max(bsr, 1)) / 6.0)
|
| 63 |
-
rating_perfection = 1.0 if rating >= 4.85 else 0.0
|
| 64 |
-
|
| 65 |
-
fake_risk = 0
|
| 66 |
-
if rating >= 4.9 and review_count > 200:
|
| 67 |
-
fake_risk += 35
|
| 68 |
-
if review_ratio > 3.5:
|
| 69 |
-
fake_risk += 30
|
| 70 |
-
if bsr > 100_000 and review_count > 80:
|
| 71 |
-
fake_risk += 25
|
| 72 |
-
if seller_count == 1 and rating >= 4.8:
|
| 73 |
-
fake_risk += 15
|
| 74 |
-
fake_risk = min(100, fake_risk + rng.randint(-8, 8))
|
| 75 |
-
|
| 76 |
-
price_delta_30 = (bsr_norm - 0.5) * 8 - seller_count * 0.8 + (rating - 4.0) * 1.2
|
| 77 |
-
price_delta_30 += rng.gauss(0, 1.5)
|
| 78 |
-
|
| 79 |
-
demand_next = monthly_sales * (1.0 + (bsr_norm - 0.4) * 0.06) * (0.95 + rating / 20)
|
| 80 |
-
demand_next = max(1, int(demand_next + rng.gauss(0, monthly_sales * 0.05)))
|
| 81 |
-
|
| 82 |
-
niche = (
|
| 83 |
-
bsr_norm * 30
|
| 84 |
-
+ min(review_count / 50, 20)
|
| 85 |
-
+ (rating / 5) * 15
|
| 86 |
-
+ min(price / 3, 15)
|
| 87 |
-
+ min(monthly_sales / 100, 20)
|
| 88 |
-
+ (10 if seller_count <= 5 else 3)
|
| 89 |
-
+ (5 if is_fba else 0)
|
| 90 |
-
)
|
| 91 |
-
niche = min(100, max(0, niche + rng.gauss(0, 5)))
|
| 92 |
-
|
| 93 |
-
rows.append({
|
| 94 |
-
"rating": rating,
|
| 95 |
-
"review_count": float(review_count),
|
| 96 |
-
"log_bsr": math.log10(max(bsr, 1)),
|
| 97 |
-
"monthly_sales": float(monthly_sales),
|
| 98 |
-
"seller_count": float(seller_count),
|
| 99 |
-
"price": price,
|
| 100 |
-
"is_fba": float(is_fba),
|
| 101 |
-
"review_ratio": review_ratio,
|
| 102 |
-
"bsr_norm": bsr_norm,
|
| 103 |
-
"rating_perfection": rating_perfection,
|
| 104 |
-
"fake_risk": float(fake_risk),
|
| 105 |
-
"price_delta_30": price_delta_30,
|
| 106 |
-
"demand_next": float(demand_next),
|
| 107 |
-
"niche_score": niche,
|
| 108 |
-
})
|
| 109 |
-
|
| 110 |
-
X_fake = np.array([[r["rating"], r["review_count"], r["log_bsr"], r["monthly_sales"],
|
| 111 |
-
r["seller_count"], r["review_ratio"], r["rating_perfection"]] for r in rows])
|
| 112 |
-
y_fake = np.array([1 if r["fake_risk"] >= 45 else 0 for r in rows])
|
| 113 |
-
|
| 114 |
-
X_price = np.array([[r["price"], r["log_bsr"], r["rating"], r["review_count"],
|
| 115 |
-
r["seller_count"], r["bsr_norm"]] for r in rows])
|
| 116 |
-
y_price = np.array([r["price_delta_30"] for r in rows])
|
| 117 |
-
|
| 118 |
-
X_demand = np.array([[r["monthly_sales"], r["log_bsr"], r["rating"], r["review_count"],
|
| 119 |
-
r["price"], r["bsr_norm"]] for r in rows])
|
| 120 |
-
y_demand = np.array([r["demand_next"] for r in rows])
|
| 121 |
-
|
| 122 |
-
X_niche = np.array([[r["bsr_norm"], r["review_count"], r["rating"], r["price"],
|
| 123 |
-
r["monthly_sales"], r["seller_count"], r["is_fba"]] for r in rows])
|
| 124 |
-
y_niche = np.array([r["niche_score"] for r in rows])
|
| 125 |
-
|
| 126 |
-
self.fake_review_model = RandomForestClassifier(n_estimators=80, max_depth=8, random_state=42)
|
| 127 |
-
self.fake_review_model.fit(X_fake, y_fake)
|
| 128 |
-
|
| 129 |
-
self.price_model = GradientBoostingRegressor(n_estimators=60, max_depth=4, random_state=42)
|
| 130 |
-
self.price_model.fit(X_price, y_price)
|
| 131 |
-
|
| 132 |
-
self.demand_model = GradientBoostingRegressor(n_estimators=60, max_depth=4, random_state=42)
|
| 133 |
-
self.demand_model.fit(X_demand, y_demand)
|
| 134 |
-
|
| 135 |
-
self.niche_model = GradientBoostingRegressor(n_estimators=60, max_depth=4, random_state=42)
|
| 136 |
-
self.niche_model.fit(X_niche, y_niche)
|
| 137 |
-
|
| 138 |
-
self._ready = True
|
| 139 |
-
|
| 140 |
-
def _features(
|
| 141 |
-
self,
|
| 142 |
-
review_count: int,
|
| 143 |
-
rating: float,
|
| 144 |
-
bsr: Optional[int],
|
| 145 |
-
monthly_sales: Optional[int],
|
| 146 |
-
seller_count: int,
|
| 147 |
-
price: float = 0.0,
|
| 148 |
-
is_fba: bool = False,
|
| 149 |
-
) -> Tuple[Any, ...]:
|
| 150 |
-
import numpy as np
|
| 151 |
-
|
| 152 |
-
bsr_val = bsr or 500_000
|
| 153 |
-
ms = monthly_sales or 1
|
| 154 |
-
log_bsr = math.log10(max(bsr_val, 1))
|
| 155 |
-
bsr_norm = max(0.0, 1.0 - log_bsr / 6.0)
|
| 156 |
-
review_ratio = review_count / max(ms * 12 * 0.04, 1)
|
| 157 |
-
rating_perfection = 1.0 if rating >= 4.85 else 0.0
|
| 158 |
-
|
| 159 |
-
fake = np.array([[rating, review_count, log_bsr, ms, seller_count, review_ratio, rating_perfection]])
|
| 160 |
-
price_f = np.array([[price or 24.0, log_bsr, rating, review_count, seller_count, bsr_norm]])
|
| 161 |
-
demand_f = np.array([[ms, log_bsr, rating, review_count, price or 24.0, bsr_norm]])
|
| 162 |
-
niche_f = np.array([[bsr_norm, review_count, rating, price or 24.0, ms, seller_count, 1.0 if is_fba else 0.0]])
|
| 163 |
-
return fake, price_f, demand_f, niche_f, bsr_norm, review_ratio
|
| 164 |
-
|
| 165 |
-
def predict_fake_review_risk(
|
| 166 |
-
self,
|
| 167 |
-
review_count: int,
|
| 168 |
-
rating: float,
|
| 169 |
-
bsr: Optional[int],
|
| 170 |
-
monthly_sales: Optional[int],
|
| 171 |
-
seller_count: int,
|
| 172 |
-
) -> Dict[str, Any]:
|
| 173 |
-
import numpy as np
|
| 174 |
-
|
| 175 |
-
fake_f, _, _, _, _, review_ratio = self._features(
|
| 176 |
-
review_count, rating, bsr, monthly_sales, seller_count
|
| 177 |
-
)
|
| 178 |
-
proba = self.fake_review_model.predict_proba(fake_f)[0]
|
| 179 |
-
risk_class = int(self.fake_review_model.predict(fake_f)[0])
|
| 180 |
-
risk_score = int(min(100, max(0, proba[1] * 100 if len(proba) > 1 else proba[0] * 100)))
|
| 181 |
-
if risk_class == 0:
|
| 182 |
-
risk_score = min(risk_score, 40)
|
| 183 |
-
|
| 184 |
-
if risk_score >= 65:
|
| 185 |
-
level = "very_high"
|
| 186 |
-
elif risk_score >= 45:
|
| 187 |
-
level = "high"
|
| 188 |
-
elif risk_score >= 25:
|
| 189 |
-
level = "medium"
|
| 190 |
-
else:
|
| 191 |
-
level = "low"
|
| 192 |
-
|
| 193 |
-
return {
|
| 194 |
-
"risk_score": risk_score,
|
| 195 |
-
"risk_level": level,
|
| 196 |
-
"trust_score": 100 - risk_score,
|
| 197 |
-
"confidence": 78,
|
| 198 |
-
"model": "RandomForestClassifier",
|
| 199 |
-
"engine": "sklearn",
|
| 200 |
-
"model_version": self.MODEL_VERSION,
|
| 201 |
-
"features_used": ["rating", "review_count", "log_bsr", "monthly_sales", "seller_count", "review_ratio"],
|
| 202 |
-
"review_ratio": round(review_ratio, 2),
|
| 203 |
-
"risk_class": risk_class,
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
def predict_price_delta(
|
| 207 |
-
self,
|
| 208 |
-
current_price: float,
|
| 209 |
-
bsr: Optional[int],
|
| 210 |
-
rating: float,
|
| 211 |
-
review_count: int,
|
| 212 |
-
seller_count: int,
|
| 213 |
-
) -> Dict[str, Any]:
|
| 214 |
-
_, price_f, _, _, bsr_norm, _ = self._features(
|
| 215 |
-
review_count, rating, bsr, None, seller_count, current_price
|
| 216 |
-
)
|
| 217 |
-
delta_30 = float(self.price_model.predict(price_f)[0])
|
| 218 |
-
delta_60 = delta_30 * 1.65
|
| 219 |
-
delta_90 = delta_30 * 2.35
|
| 220 |
-
|
| 221 |
-
def point(days: int, delta: float) -> Dict[str, float]:
|
| 222 |
-
pct = delta
|
| 223 |
-
pred = round(max(0.01, current_price * (1 + pct / 100)), 2)
|
| 224 |
-
spread = abs(pred * 0.06)
|
| 225 |
-
return {
|
| 226 |
-
"price": pred,
|
| 227 |
-
"change_pct": round(pct, 2),
|
| 228 |
-
"low": round(max(0.01, pred - spread), 2),
|
| 229 |
-
"high": round(pred + spread, 2),
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
direction = "up" if delta_30 > 1.5 else "down" if delta_30 < -1.5 else "stable"
|
| 233 |
-
labels = {"up": "Trending Up", "down": "Trending Down", "stable": "Stable"}
|
| 234 |
-
|
| 235 |
-
return {
|
| 236 |
-
"monthly_change_pct": round(delta_30, 2),
|
| 237 |
-
"direction": direction,
|
| 238 |
-
"direction_label": labels[direction],
|
| 239 |
-
"confidence": 72,
|
| 240 |
-
"model": "GradientBoostingRegressor",
|
| 241 |
-
"engine": "sklearn",
|
| 242 |
-
"model_version": self.MODEL_VERSION,
|
| 243 |
-
"predictions": {
|
| 244 |
-
"days_30": point(30, delta_30),
|
| 245 |
-
"days_60": point(60, delta_60),
|
| 246 |
-
"days_90": point(90, delta_90),
|
| 247 |
-
},
|
| 248 |
-
"model_factors": {
|
| 249 |
-
"bsr_pressure": round((bsr_norm - 0.5) * 100, 1),
|
| 250 |
-
"competition_pressure": round(-seller_count * 12, 1),
|
| 251 |
-
"review_momentum": round((rating - 3.5) * 20, 1),
|
| 252 |
-
},
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
def predict_demand(
|
| 256 |
-
self,
|
| 257 |
-
bsr: Optional[int],
|
| 258 |
-
monthly_sales: int,
|
| 259 |
-
rating: float,
|
| 260 |
-
review_count: int,
|
| 261 |
-
price: float,
|
| 262 |
-
) -> Dict[str, Any]:
|
| 263 |
-
_, _, demand_f, _, _, _ = self._features(
|
| 264 |
-
review_count, rating, bsr, monthly_sales, 1, price
|
| 265 |
-
)
|
| 266 |
-
next_units = max(1, int(self.demand_model.predict(demand_f)[0]))
|
| 267 |
-
trend = "growing" if next_units > monthly_sales * 1.05 else "declining" if next_units < monthly_sales * 0.95 else "stable"
|
| 268 |
-
|
| 269 |
-
return {
|
| 270 |
-
"next_month_units": next_units,
|
| 271 |
-
"trend": trend,
|
| 272 |
-
"confidence": 70,
|
| 273 |
-
"model": "GradientBoostingRegressor",
|
| 274 |
-
"engine": "sklearn",
|
| 275 |
-
"model_version": self.MODEL_VERSION,
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
def predict_niche_score(
|
| 279 |
-
self,
|
| 280 |
-
bsr: Optional[int],
|
| 281 |
-
review_count: int,
|
| 282 |
-
rating: float,
|
| 283 |
-
price: float,
|
| 284 |
-
monthly_sales: int,
|
| 285 |
-
seller_count: int,
|
| 286 |
-
is_fba: bool,
|
| 287 |
-
) -> Dict[str, Any]:
|
| 288 |
-
_, _, _, niche_f, _, _ = self._features(
|
| 289 |
-
review_count, rating, bsr, monthly_sales, seller_count, price, is_fba
|
| 290 |
-
)
|
| 291 |
-
score = int(min(100, max(0, round(float(self.niche_model.predict(niche_f)[0])))))
|
| 292 |
-
|
| 293 |
-
if score >= 80:
|
| 294 |
-
grade, verdict, action = "A", "Excellent niche opportunity", "Prioritize sourcing and listing tests"
|
| 295 |
-
elif score >= 60:
|
| 296 |
-
grade, verdict, action = "B", "Good opportunity with manageable risk", "Validate suppliers and differentiation"
|
| 297 |
-
elif score >= 40:
|
| 298 |
-
grade, verdict, action = "C", "Average — needs a clear USP", "Find a sub-niche or bundle angle"
|
| 299 |
-
elif score >= 20:
|
| 300 |
-
grade, verdict, action = "D", "Difficult market entry", "Only proceed with strong differentiation"
|
| 301 |
-
else:
|
| 302 |
-
grade, verdict, action = "F", "Not recommended", "Look for better opportunities"
|
| 303 |
-
|
| 304 |
-
return {
|
| 305 |
-
"niche_score": score,
|
| 306 |
-
"grade": grade,
|
| 307 |
-
"verdict": verdict,
|
| 308 |
-
"action": action,
|
| 309 |
-
"model": "GradientBoostingRegressor",
|
| 310 |
-
"engine": "sklearn",
|
| 311 |
-
"model_version": self.MODEL_VERSION,
|
| 312 |
-
}
|
|
|
|
| 1 |
+
# app/services/ml/sklearn_engine.py
|
| 2 |
+
"""Scikit-learn ML engine for Rankora.
|
| 3 |
+
|
| 4 |
+
Trains lightweight models on a synthetic Amazon seller dataset at first use.
|
| 5 |
+
Models are real ML (RandomForest, GradientBoosting) — not pure heuristics.
|
| 6 |
+
Falls back gracefully if scikit-learn is unavailable.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
import random
|
| 12 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
_ENGINE: Optional["SklearnEngine"] = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_engine() -> Optional["SklearnEngine"]:
|
| 18 |
+
global _ENGINE
|
| 19 |
+
if _ENGINE is None:
|
| 20 |
+
try:
|
| 21 |
+
_ENGINE = SklearnEngine()
|
| 22 |
+
_ENGINE.train()
|
| 23 |
+
except Exception as e:
|
| 24 |
+
print(f"[sklearn_engine] init failed: {e}")
|
| 25 |
+
_ENGINE = None
|
| 26 |
+
return _ENGINE
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def is_sklearn_available() -> bool:
|
| 30 |
+
return get_engine() is not None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class SklearnEngine:
|
| 34 |
+
"""Singleton trained models for product intelligence."""
|
| 35 |
+
|
| 36 |
+
MODEL_VERSION = "1.0.0"
|
| 37 |
+
|
| 38 |
+
def __init__(self) -> None:
|
| 39 |
+
self.fake_review_model = None
|
| 40 |
+
self.price_model = None
|
| 41 |
+
self.demand_model = None
|
| 42 |
+
self.niche_model = None
|
| 43 |
+
self._ready = False
|
| 44 |
+
|
| 45 |
+
def train(self) -> None:
|
| 46 |
+
import numpy as np
|
| 47 |
+
from sklearn.ensemble import GradientBoostingRegressor, RandomForestClassifier
|
| 48 |
+
|
| 49 |
+
rng = random.Random(42)
|
| 50 |
+
rows: List[Dict[str, float]] = []
|
| 51 |
+
|
| 52 |
+
for _ in range(2500):
|
| 53 |
+
bsr = rng.choice([rng.randint(50, 5000), rng.randint(5000, 80000), rng.randint(80000, 900000)])
|
| 54 |
+
rating = round(min(5.0, max(2.0, rng.gauss(4.2, 0.45))), 2)
|
| 55 |
+
review_count = int(max(0, rng.gauss(bsr / 15, bsr / 40)))
|
| 56 |
+
monthly_sales = max(1, int(8500 / math.log10(bsr + 10)))
|
| 57 |
+
seller_count = rng.randint(1, 12)
|
| 58 |
+
price = round(max(8.0, rng.gauss(28, 14)), 2)
|
| 59 |
+
is_fba = 1 if rng.random() > 0.25 else 0
|
| 60 |
+
|
| 61 |
+
review_ratio = review_count / max(monthly_sales * 12 * 0.04, 1)
|
| 62 |
+
bsr_norm = max(0.0, 1.0 - math.log10(max(bsr, 1)) / 6.0)
|
| 63 |
+
rating_perfection = 1.0 if rating >= 4.85 else 0.0
|
| 64 |
+
|
| 65 |
+
fake_risk = 0
|
| 66 |
+
if rating >= 4.9 and review_count > 200:
|
| 67 |
+
fake_risk += 35
|
| 68 |
+
if review_ratio > 3.5:
|
| 69 |
+
fake_risk += 30
|
| 70 |
+
if bsr > 100_000 and review_count > 80:
|
| 71 |
+
fake_risk += 25
|
| 72 |
+
if seller_count == 1 and rating >= 4.8:
|
| 73 |
+
fake_risk += 15
|
| 74 |
+
fake_risk = min(100, fake_risk + rng.randint(-8, 8))
|
| 75 |
+
|
| 76 |
+
price_delta_30 = (bsr_norm - 0.5) * 8 - seller_count * 0.8 + (rating - 4.0) * 1.2
|
| 77 |
+
price_delta_30 += rng.gauss(0, 1.5)
|
| 78 |
+
|
| 79 |
+
demand_next = monthly_sales * (1.0 + (bsr_norm - 0.4) * 0.06) * (0.95 + rating / 20)
|
| 80 |
+
demand_next = max(1, int(demand_next + rng.gauss(0, monthly_sales * 0.05)))
|
| 81 |
+
|
| 82 |
+
niche = (
|
| 83 |
+
bsr_norm * 30
|
| 84 |
+
+ min(review_count / 50, 20)
|
| 85 |
+
+ (rating / 5) * 15
|
| 86 |
+
+ min(price / 3, 15)
|
| 87 |
+
+ min(monthly_sales / 100, 20)
|
| 88 |
+
+ (10 if seller_count <= 5 else 3)
|
| 89 |
+
+ (5 if is_fba else 0)
|
| 90 |
+
)
|
| 91 |
+
niche = min(100, max(0, niche + rng.gauss(0, 5)))
|
| 92 |
+
|
| 93 |
+
rows.append({
|
| 94 |
+
"rating": rating,
|
| 95 |
+
"review_count": float(review_count),
|
| 96 |
+
"log_bsr": math.log10(max(bsr, 1)),
|
| 97 |
+
"monthly_sales": float(monthly_sales),
|
| 98 |
+
"seller_count": float(seller_count),
|
| 99 |
+
"price": price,
|
| 100 |
+
"is_fba": float(is_fba),
|
| 101 |
+
"review_ratio": review_ratio,
|
| 102 |
+
"bsr_norm": bsr_norm,
|
| 103 |
+
"rating_perfection": rating_perfection,
|
| 104 |
+
"fake_risk": float(fake_risk),
|
| 105 |
+
"price_delta_30": price_delta_30,
|
| 106 |
+
"demand_next": float(demand_next),
|
| 107 |
+
"niche_score": niche,
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
X_fake = np.array([[r["rating"], r["review_count"], r["log_bsr"], r["monthly_sales"],
|
| 111 |
+
r["seller_count"], r["review_ratio"], r["rating_perfection"]] for r in rows])
|
| 112 |
+
y_fake = np.array([1 if r["fake_risk"] >= 45 else 0 for r in rows])
|
| 113 |
+
|
| 114 |
+
X_price = np.array([[r["price"], r["log_bsr"], r["rating"], r["review_count"],
|
| 115 |
+
r["seller_count"], r["bsr_norm"]] for r in rows])
|
| 116 |
+
y_price = np.array([r["price_delta_30"] for r in rows])
|
| 117 |
+
|
| 118 |
+
X_demand = np.array([[r["monthly_sales"], r["log_bsr"], r["rating"], r["review_count"],
|
| 119 |
+
r["price"], r["bsr_norm"]] for r in rows])
|
| 120 |
+
y_demand = np.array([r["demand_next"] for r in rows])
|
| 121 |
+
|
| 122 |
+
X_niche = np.array([[r["bsr_norm"], r["review_count"], r["rating"], r["price"],
|
| 123 |
+
r["monthly_sales"], r["seller_count"], r["is_fba"]] for r in rows])
|
| 124 |
+
y_niche = np.array([r["niche_score"] for r in rows])
|
| 125 |
+
|
| 126 |
+
self.fake_review_model = RandomForestClassifier(n_estimators=80, max_depth=8, random_state=42)
|
| 127 |
+
self.fake_review_model.fit(X_fake, y_fake)
|
| 128 |
+
|
| 129 |
+
self.price_model = GradientBoostingRegressor(n_estimators=60, max_depth=4, random_state=42)
|
| 130 |
+
self.price_model.fit(X_price, y_price)
|
| 131 |
+
|
| 132 |
+
self.demand_model = GradientBoostingRegressor(n_estimators=60, max_depth=4, random_state=42)
|
| 133 |
+
self.demand_model.fit(X_demand, y_demand)
|
| 134 |
+
|
| 135 |
+
self.niche_model = GradientBoostingRegressor(n_estimators=60, max_depth=4, random_state=42)
|
| 136 |
+
self.niche_model.fit(X_niche, y_niche)
|
| 137 |
+
|
| 138 |
+
self._ready = True
|
| 139 |
+
|
| 140 |
+
def _features(
|
| 141 |
+
self,
|
| 142 |
+
review_count: int,
|
| 143 |
+
rating: float,
|
| 144 |
+
bsr: Optional[int],
|
| 145 |
+
monthly_sales: Optional[int],
|
| 146 |
+
seller_count: int,
|
| 147 |
+
price: float = 0.0,
|
| 148 |
+
is_fba: bool = False,
|
| 149 |
+
) -> Tuple[Any, ...]:
|
| 150 |
+
import numpy as np
|
| 151 |
+
|
| 152 |
+
bsr_val = bsr or 500_000
|
| 153 |
+
ms = monthly_sales or 1
|
| 154 |
+
log_bsr = math.log10(max(bsr_val, 1))
|
| 155 |
+
bsr_norm = max(0.0, 1.0 - log_bsr / 6.0)
|
| 156 |
+
review_ratio = review_count / max(ms * 12 * 0.04, 1)
|
| 157 |
+
rating_perfection = 1.0 if rating >= 4.85 else 0.0
|
| 158 |
+
|
| 159 |
+
fake = np.array([[rating, review_count, log_bsr, ms, seller_count, review_ratio, rating_perfection]])
|
| 160 |
+
price_f = np.array([[price or 24.0, log_bsr, rating, review_count, seller_count, bsr_norm]])
|
| 161 |
+
demand_f = np.array([[ms, log_bsr, rating, review_count, price or 24.0, bsr_norm]])
|
| 162 |
+
niche_f = np.array([[bsr_norm, review_count, rating, price or 24.0, ms, seller_count, 1.0 if is_fba else 0.0]])
|
| 163 |
+
return fake, price_f, demand_f, niche_f, bsr_norm, review_ratio
|
| 164 |
+
|
| 165 |
+
def predict_fake_review_risk(
|
| 166 |
+
self,
|
| 167 |
+
review_count: int,
|
| 168 |
+
rating: float,
|
| 169 |
+
bsr: Optional[int],
|
| 170 |
+
monthly_sales: Optional[int],
|
| 171 |
+
seller_count: int,
|
| 172 |
+
) -> Dict[str, Any]:
|
| 173 |
+
import numpy as np
|
| 174 |
+
|
| 175 |
+
fake_f, _, _, _, _, review_ratio = self._features(
|
| 176 |
+
review_count, rating, bsr, monthly_sales, seller_count
|
| 177 |
+
)
|
| 178 |
+
proba = self.fake_review_model.predict_proba(fake_f)[0]
|
| 179 |
+
risk_class = int(self.fake_review_model.predict(fake_f)[0])
|
| 180 |
+
risk_score = int(min(100, max(0, proba[1] * 100 if len(proba) > 1 else proba[0] * 100)))
|
| 181 |
+
if risk_class == 0:
|
| 182 |
+
risk_score = min(risk_score, 40)
|
| 183 |
+
|
| 184 |
+
if risk_score >= 65:
|
| 185 |
+
level = "very_high"
|
| 186 |
+
elif risk_score >= 45:
|
| 187 |
+
level = "high"
|
| 188 |
+
elif risk_score >= 25:
|
| 189 |
+
level = "medium"
|
| 190 |
+
else:
|
| 191 |
+
level = "low"
|
| 192 |
+
|
| 193 |
+
return {
|
| 194 |
+
"risk_score": risk_score,
|
| 195 |
+
"risk_level": level,
|
| 196 |
+
"trust_score": 100 - risk_score,
|
| 197 |
+
"confidence": 78,
|
| 198 |
+
"model": "RandomForestClassifier",
|
| 199 |
+
"engine": "sklearn",
|
| 200 |
+
"model_version": self.MODEL_VERSION,
|
| 201 |
+
"features_used": ["rating", "review_count", "log_bsr", "monthly_sales", "seller_count", "review_ratio"],
|
| 202 |
+
"review_ratio": round(review_ratio, 2),
|
| 203 |
+
"risk_class": risk_class,
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
def predict_price_delta(
|
| 207 |
+
self,
|
| 208 |
+
current_price: float,
|
| 209 |
+
bsr: Optional[int],
|
| 210 |
+
rating: float,
|
| 211 |
+
review_count: int,
|
| 212 |
+
seller_count: int,
|
| 213 |
+
) -> Dict[str, Any]:
|
| 214 |
+
_, price_f, _, _, bsr_norm, _ = self._features(
|
| 215 |
+
review_count, rating, bsr, None, seller_count, current_price
|
| 216 |
+
)
|
| 217 |
+
delta_30 = float(self.price_model.predict(price_f)[0])
|
| 218 |
+
delta_60 = delta_30 * 1.65
|
| 219 |
+
delta_90 = delta_30 * 2.35
|
| 220 |
+
|
| 221 |
+
def point(days: int, delta: float) -> Dict[str, float]:
|
| 222 |
+
pct = delta
|
| 223 |
+
pred = round(max(0.01, current_price * (1 + pct / 100)), 2)
|
| 224 |
+
spread = abs(pred * 0.06)
|
| 225 |
+
return {
|
| 226 |
+
"price": pred,
|
| 227 |
+
"change_pct": round(pct, 2),
|
| 228 |
+
"low": round(max(0.01, pred - spread), 2),
|
| 229 |
+
"high": round(pred + spread, 2),
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
direction = "up" if delta_30 > 1.5 else "down" if delta_30 < -1.5 else "stable"
|
| 233 |
+
labels = {"up": "Trending Up", "down": "Trending Down", "stable": "Stable"}
|
| 234 |
+
|
| 235 |
+
return {
|
| 236 |
+
"monthly_change_pct": round(delta_30, 2),
|
| 237 |
+
"direction": direction,
|
| 238 |
+
"direction_label": labels[direction],
|
| 239 |
+
"confidence": 72,
|
| 240 |
+
"model": "GradientBoostingRegressor",
|
| 241 |
+
"engine": "sklearn",
|
| 242 |
+
"model_version": self.MODEL_VERSION,
|
| 243 |
+
"predictions": {
|
| 244 |
+
"days_30": point(30, delta_30),
|
| 245 |
+
"days_60": point(60, delta_60),
|
| 246 |
+
"days_90": point(90, delta_90),
|
| 247 |
+
},
|
| 248 |
+
"model_factors": {
|
| 249 |
+
"bsr_pressure": round((bsr_norm - 0.5) * 100, 1),
|
| 250 |
+
"competition_pressure": round(-seller_count * 12, 1),
|
| 251 |
+
"review_momentum": round((rating - 3.5) * 20, 1),
|
| 252 |
+
},
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
def predict_demand(
|
| 256 |
+
self,
|
| 257 |
+
bsr: Optional[int],
|
| 258 |
+
monthly_sales: int,
|
| 259 |
+
rating: float,
|
| 260 |
+
review_count: int,
|
| 261 |
+
price: float,
|
| 262 |
+
) -> Dict[str, Any]:
|
| 263 |
+
_, _, demand_f, _, _, _ = self._features(
|
| 264 |
+
review_count, rating, bsr, monthly_sales, 1, price
|
| 265 |
+
)
|
| 266 |
+
next_units = max(1, int(self.demand_model.predict(demand_f)[0]))
|
| 267 |
+
trend = "growing" if next_units > monthly_sales * 1.05 else "declining" if next_units < monthly_sales * 0.95 else "stable"
|
| 268 |
+
|
| 269 |
+
return {
|
| 270 |
+
"next_month_units": next_units,
|
| 271 |
+
"trend": trend,
|
| 272 |
+
"confidence": 70,
|
| 273 |
+
"model": "GradientBoostingRegressor",
|
| 274 |
+
"engine": "sklearn",
|
| 275 |
+
"model_version": self.MODEL_VERSION,
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
def predict_niche_score(
|
| 279 |
+
self,
|
| 280 |
+
bsr: Optional[int],
|
| 281 |
+
review_count: int,
|
| 282 |
+
rating: float,
|
| 283 |
+
price: float,
|
| 284 |
+
monthly_sales: int,
|
| 285 |
+
seller_count: int,
|
| 286 |
+
is_fba: bool,
|
| 287 |
+
) -> Dict[str, Any]:
|
| 288 |
+
_, _, _, niche_f, _, _ = self._features(
|
| 289 |
+
review_count, rating, bsr, monthly_sales, seller_count, price, is_fba
|
| 290 |
+
)
|
| 291 |
+
score = int(min(100, max(0, round(float(self.niche_model.predict(niche_f)[0])))))
|
| 292 |
+
|
| 293 |
+
if score >= 80:
|
| 294 |
+
grade, verdict, action = "A", "Excellent niche opportunity", "Prioritize sourcing and listing tests"
|
| 295 |
+
elif score >= 60:
|
| 296 |
+
grade, verdict, action = "B", "Good opportunity with manageable risk", "Validate suppliers and differentiation"
|
| 297 |
+
elif score >= 40:
|
| 298 |
+
grade, verdict, action = "C", "Average — needs a clear USP", "Find a sub-niche or bundle angle"
|
| 299 |
+
elif score >= 20:
|
| 300 |
+
grade, verdict, action = "D", "Difficult market entry", "Only proceed with strong differentiation"
|
| 301 |
+
else:
|
| 302 |
+
grade, verdict, action = "F", "Not recommended", "Look for better opportunities"
|
| 303 |
+
|
| 304 |
+
return {
|
| 305 |
+
"niche_score": score,
|
| 306 |
+
"grade": grade,
|
| 307 |
+
"verdict": verdict,
|
| 308 |
+
"action": action,
|
| 309 |
+
"model": "GradientBoostingRegressor",
|
| 310 |
+
"engine": "sklearn",
|
| 311 |
+
"model_version": self.MODEL_VERSION,
|
| 312 |
+
}
|
app/services/product_service.py
CHANGED
|
@@ -1,418 +1,504 @@
|
|
| 1 |
-
"""Shared product fetch, persist, and response building."""
|
| 2 |
-
import
|
| 3 |
-
|
| 4 |
-
from
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
from app.
|
| 10 |
-
from app.services.amazon.
|
| 11 |
-
from app.services.
|
| 12 |
-
from app.services.analytics.
|
| 13 |
-
from app.services.analytics.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
"
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
"
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
return
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
"
|
| 299 |
-
"
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
)
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
"
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
"
|
| 335 |
-
"
|
| 336 |
-
"
|
| 337 |
-
"
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
if
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared product fetch, persist, and response building."""
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.models.product import Product, PriceHistory
|
| 10 |
+
from app.services.amazon.product_scraper import scrape_amazon_product
|
| 11 |
+
from app.services.amazon.sales_estimator import estimate_monthly_sales, calculate_opportunity_score
|
| 12 |
+
from app.services.analytics.tracking_service import check_alerts
|
| 13 |
+
from app.services.analytics.buy_box_rotation import estimate_buy_box_rotation
|
| 14 |
+
from app.services.analytics.buy_box_history import (
|
| 15 |
+
record_buy_box_snapshot,
|
| 16 |
+
get_buy_box_snapshots,
|
| 17 |
+
rotation_from_snapshots,
|
| 18 |
+
build_buy_box_timeline,
|
| 19 |
+
build_historical_charts_payload,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _load_stored_offers(product: Product) -> List[dict]:
|
| 24 |
+
raw = getattr(product, "other_sellers_json", None)
|
| 25 |
+
if not raw:
|
| 26 |
+
return []
|
| 27 |
+
try:
|
| 28 |
+
data = json.loads(raw)
|
| 29 |
+
return data if isinstance(data, list) else []
|
| 30 |
+
except (json.JSONDecodeError, TypeError):
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def build_all_competitors(data: dict) -> List[dict]:
|
| 35 |
+
"""Merge buy box winner + other sellers into one sorted competitor list with prices."""
|
| 36 |
+
bb_price = float(data["buy_box_price"]) if data.get("buy_box_price") else None
|
| 37 |
+
winner = (data.get("buy_box_winner") or "").strip()
|
| 38 |
+
has_bb = data.get("has_buy_box", True)
|
| 39 |
+
rows: List[dict] = []
|
| 40 |
+
seen: set = set()
|
| 41 |
+
|
| 42 |
+
def add_row(
|
| 43 |
+
seller: str,
|
| 44 |
+
price,
|
| 45 |
+
is_fba,
|
| 46 |
+
is_prime,
|
| 47 |
+
is_winner: bool,
|
| 48 |
+
rating=None,
|
| 49 |
+
):
|
| 50 |
+
if not seller:
|
| 51 |
+
return
|
| 52 |
+
key = seller.lower()
|
| 53 |
+
if key in seen:
|
| 54 |
+
return
|
| 55 |
+
seen.add(key)
|
| 56 |
+
p = float(price) if price is not None else None
|
| 57 |
+
delta = round(p - bb_price, 2) if bb_price is not None and p is not None else None
|
| 58 |
+
rows.append(
|
| 59 |
+
{
|
| 60 |
+
"seller": seller,
|
| 61 |
+
"price": p,
|
| 62 |
+
"is_fba": is_fba,
|
| 63 |
+
"is_prime": bool(is_prime),
|
| 64 |
+
"is_buy_box_winner": is_winner,
|
| 65 |
+
"fulfillment": "FBA" if is_fba is True else ("FBM" if is_fba is False else "—"),
|
| 66 |
+
"price_vs_buy_box": delta,
|
| 67 |
+
"rating": rating,
|
| 68 |
+
}
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
if winner and has_bb:
|
| 72 |
+
add_row(
|
| 73 |
+
winner,
|
| 74 |
+
bb_price,
|
| 75 |
+
data.get("buy_box_is_fba"),
|
| 76 |
+
data.get("buy_box_is_prime", data.get("is_prime")),
|
| 77 |
+
True,
|
| 78 |
+
"98%" if data.get("is_amazon_sold") else "95%",
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
for offer in data.get("other_sellers") or []:
|
| 82 |
+
add_row(
|
| 83 |
+
(offer.get("name") or "").strip(),
|
| 84 |
+
offer.get("price"),
|
| 85 |
+
offer.get("is_fba"),
|
| 86 |
+
offer.get("is_prime"),
|
| 87 |
+
False,
|
| 88 |
+
offer.get("rating"),
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
rows.sort(key=lambda r: (not r["is_buy_box_winner"], r["price"] if r["price"] is not None else 1e9))
|
| 92 |
+
return rows
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _buy_box_data_from_product(product: Product) -> dict:
|
| 96 |
+
has_bb = product.has_buy_box if product.has_buy_box is not None else True
|
| 97 |
+
is_fba = product.buy_box_is_fba
|
| 98 |
+
seller_count = product.seller_count or 1
|
| 99 |
+
other_sellers = _load_stored_offers(product)
|
| 100 |
+
fba_sellers = 0
|
| 101 |
+
fbm_sellers = 0
|
| 102 |
+
if is_fba is True:
|
| 103 |
+
fba_sellers = max(1, seller_count - 1) if seller_count > 1 else 1
|
| 104 |
+
fbm_sellers = max(0, seller_count - fba_sellers)
|
| 105 |
+
elif is_fba is False:
|
| 106 |
+
fbm_sellers = max(1, seller_count)
|
| 107 |
+
fba_sellers = max(0, seller_count - fbm_sellers)
|
| 108 |
+
elif other_sellers:
|
| 109 |
+
fba_sellers = sum(1 for o in other_sellers if o.get("is_fba") is True)
|
| 110 |
+
fbm_sellers = sum(1 for o in other_sellers if o.get("is_fba") is False)
|
| 111 |
+
return {
|
| 112 |
+
"buy_box_winner": product.buy_box_winner,
|
| 113 |
+
"buy_box_price": float(product.buy_box_price) if product.buy_box_price else None,
|
| 114 |
+
"buy_box_is_fba": is_fba,
|
| 115 |
+
"buy_box_is_prime": product.is_prime,
|
| 116 |
+
"is_amazon_sold": product.is_amazon_sold or False,
|
| 117 |
+
"seller_count": seller_count,
|
| 118 |
+
"fba_seller_count": fba_sellers,
|
| 119 |
+
"fbm_seller_count": fbm_sellers,
|
| 120 |
+
"has_buy_box": has_bb,
|
| 121 |
+
"other_sellers": other_sellers,
|
| 122 |
+
"offers_source": getattr(product, "offers_source", None) or product.last_data_source,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def build_buy_box_analysis(
|
| 127 |
+
data: dict,
|
| 128 |
+
price: float,
|
| 129 |
+
rating: float,
|
| 130 |
+
reviews: int,
|
| 131 |
+
rotation: Optional[dict] = None,
|
| 132 |
+
history_timeline: Optional[list] = None,
|
| 133 |
+
) -> dict:
|
| 134 |
+
"""Build complete buy box analysis from scraped + calculated data."""
|
| 135 |
+
buy_box_winner = data.get("buy_box_winner") or "Unknown"
|
| 136 |
+
buy_box_price = data.get("buy_box_price") or price
|
| 137 |
+
is_fba = data.get("buy_box_is_fba")
|
| 138 |
+
is_prime = data.get("buy_box_is_prime", True)
|
| 139 |
+
is_amazon = data.get("is_amazon_sold", False)
|
| 140 |
+
seller_count = data.get("seller_count", 1)
|
| 141 |
+
has_buy_box = data.get("has_buy_box", True)
|
| 142 |
+
fba_seller_count = data.get("fba_seller_count")
|
| 143 |
+
fbm_seller_count = data.get("fbm_seller_count")
|
| 144 |
+
if fba_seller_count is None and fbm_seller_count is None:
|
| 145 |
+
if is_fba is True:
|
| 146 |
+
fba_seller_count = max(1, seller_count)
|
| 147 |
+
fbm_seller_count = max(0, seller_count - fba_seller_count)
|
| 148 |
+
elif is_fba is False:
|
| 149 |
+
fbm_seller_count = max(1, seller_count)
|
| 150 |
+
fba_seller_count = max(0, seller_count - fbm_seller_count)
|
| 151 |
+
else:
|
| 152 |
+
fba_seller_count = None
|
| 153 |
+
fbm_seller_count = None
|
| 154 |
+
|
| 155 |
+
score = 0
|
| 156 |
+
factors = []
|
| 157 |
+
|
| 158 |
+
if price and buy_box_price:
|
| 159 |
+
price_diff_pct = abs(price - buy_box_price) / buy_box_price * 100
|
| 160 |
+
if price_diff_pct <= 2:
|
| 161 |
+
score += 25
|
| 162 |
+
factors.append({"factor": "Price", "status": "Competitive ✓", "color": "#10B981",
|
| 163 |
+
"detail": f"${price} is within 2% of buy box price (${buy_box_price})"})
|
| 164 |
+
elif price_diff_pct <= 10:
|
| 165 |
+
score += 15
|
| 166 |
+
factors.append({"factor": "Price", "status": "Close", "color": "#F59E0B",
|
| 167 |
+
"detail": f"${price} is {price_diff_pct:.1f}% from buy box price (${buy_box_price})"})
|
| 168 |
+
else:
|
| 169 |
+
score += 5
|
| 170 |
+
factors.append({"factor": "Price", "status": "Too High", "color": "#EF4444",
|
| 171 |
+
"detail": f"${price} is {price_diff_pct:.1f}% above buy box price — lower price to compete"})
|
| 172 |
+
else:
|
| 173 |
+
score += 15
|
| 174 |
+
factors.append({"factor": "Price", "status": "Unknown", "color": "#9CA3AF", "detail": "Could not determine price gap"})
|
| 175 |
+
|
| 176 |
+
if is_fba is True:
|
| 177 |
+
score += 30
|
| 178 |
+
factors.append({"factor": "Fulfillment", "status": "FBA ✓", "color": "#10B981",
|
| 179 |
+
"detail": "FBA strongly favored — Amazon prefers its own fulfillment"})
|
| 180 |
+
elif is_fba is False:
|
| 181 |
+
score += 5
|
| 182 |
+
factors.append({"factor": "Fulfillment", "status": "FBM ✗", "color": "#EF4444",
|
| 183 |
+
"detail": "FBM sellers rarely win buy box — switch to FBA"})
|
| 184 |
+
else:
|
| 185 |
+
score += 10
|
| 186 |
+
factors.append({"factor": "Fulfillment", "status": "Unknown", "color": "#9CA3AF",
|
| 187 |
+
"detail": "Fulfillment type unclear — refresh product data"})
|
| 188 |
+
|
| 189 |
+
if is_prime:
|
| 190 |
+
score += 20
|
| 191 |
+
factors.append({"factor": "Prime Badge", "status": "Eligible ✓", "color": "#10B981",
|
| 192 |
+
"detail": "Prime eligible — required for consistent buy box wins"})
|
| 193 |
+
else:
|
| 194 |
+
factors.append({"factor": "Prime Badge", "status": "Not Eligible ✗", "color": "#EF4444",
|
| 195 |
+
"detail": "No Prime = very low chance of winning buy box"})
|
| 196 |
+
|
| 197 |
+
if rating and rating >= 4.5:
|
| 198 |
+
score += 15
|
| 199 |
+
factors.append({"factor": "Seller Rating", "status": "Excellent ✓", "color": "#10B981",
|
| 200 |
+
"detail": f"{rating}★ — Amazon heavily favors high-rated sellers"})
|
| 201 |
+
elif rating and rating >= 4.0:
|
| 202 |
+
score += 10
|
| 203 |
+
factors.append({"factor": "Seller Rating", "status": "Good", "color": "#F59E0B",
|
| 204 |
+
"detail": f"{rating}★ — acceptable but not ideal for buy box"})
|
| 205 |
+
else:
|
| 206 |
+
score += 3
|
| 207 |
+
factors.append({"factor": "Seller Rating", "status": "Needs Work ✗", "color": "#EF4444",
|
| 208 |
+
"detail": f"{rating or 'Unknown'}★ — low rating significantly hurts buy box eligibility"})
|
| 209 |
+
|
| 210 |
+
if seller_count <= 2:
|
| 211 |
+
score += 10
|
| 212 |
+
factors.append({"factor": "Competition", "status": f"Low ({seller_count} sellers)", "color": "#10B981",
|
| 213 |
+
"detail": "Few sellers = easier to win and keep buy box"})
|
| 214 |
+
elif seller_count <= 5:
|
| 215 |
+
score += 6
|
| 216 |
+
factors.append({"factor": "Competition", "status": f"Medium ({seller_count} sellers)", "color": "#F59E0B",
|
| 217 |
+
"detail": f"{seller_count} competing sellers — maintain competitive price"})
|
| 218 |
+
else:
|
| 219 |
+
score += 2
|
| 220 |
+
factors.append({"factor": "Competition", "status": f"High ({seller_count}+ sellers)", "color": "#EF4444",
|
| 221 |
+
"detail": f"{seller_count}+ sellers competing — very hard to win consistently"})
|
| 222 |
+
|
| 223 |
+
score = min(score, 100)
|
| 224 |
+
|
| 225 |
+
if score >= 75:
|
| 226 |
+
verdict, verdict_color = "Strong Buy Box Candidate", "#10B981"
|
| 227 |
+
elif score >= 50:
|
| 228 |
+
verdict, verdict_color = "Moderate Chance", "#F59E0B"
|
| 229 |
+
elif score >= 25:
|
| 230 |
+
verdict, verdict_color = "Low Chance", "#FB923C"
|
| 231 |
+
else:
|
| 232 |
+
verdict, verdict_color = "Unlikely to Win", "#EF4444"
|
| 233 |
+
|
| 234 |
+
owner_badge = "🟡 3rd Party"
|
| 235 |
+
owner_color = "#F59E0B"
|
| 236 |
+
if is_amazon:
|
| 237 |
+
owner_badge = "🔵 Amazon"
|
| 238 |
+
owner_color = "#3B82F6"
|
| 239 |
+
elif is_fba is True:
|
| 240 |
+
owner_badge = "🟢 FBA Seller"
|
| 241 |
+
owner_color = "#10B981"
|
| 242 |
+
elif is_fba is False:
|
| 243 |
+
owner_badge = "🟠 FBM Seller"
|
| 244 |
+
owner_color = "#F59E0B"
|
| 245 |
+
|
| 246 |
+
rotation_data = {
|
| 247 |
+
**data,
|
| 248 |
+
"buy_box_winner": buy_box_winner,
|
| 249 |
+
"buy_box_price": buy_box_price,
|
| 250 |
+
"buy_box_is_fba": is_fba,
|
| 251 |
+
"seller_count": seller_count,
|
| 252 |
+
"has_buy_box": has_buy_box,
|
| 253 |
+
"price": price,
|
| 254 |
+
}
|
| 255 |
+
if rotation is None:
|
| 256 |
+
rotation = estimate_buy_box_rotation(rotation_data) if has_buy_box else {"sellers": [], "eligible_fba": 0, "eligible_fbm": 0}
|
| 257 |
+
rotation["source"] = "estimated"
|
| 258 |
+
|
| 259 |
+
all_competitors = build_all_competitors(rotation_data)
|
| 260 |
+
offers_source = data.get("offers_source") or "unknown"
|
| 261 |
+
|
| 262 |
+
return {
|
| 263 |
+
"score": score,
|
| 264 |
+
"verdict": verdict,
|
| 265 |
+
"verdict_color": verdict_color,
|
| 266 |
+
"factors": factors,
|
| 267 |
+
"has_buy_box": has_buy_box,
|
| 268 |
+
"current_winner": buy_box_winner if has_buy_box else None,
|
| 269 |
+
"current_winner_badge": owner_badge if has_buy_box else "No Buy Box",
|
| 270 |
+
"current_winner_color": owner_color,
|
| 271 |
+
"buy_box_price": buy_box_price if has_buy_box else None,
|
| 272 |
+
"seller_count": seller_count,
|
| 273 |
+
"fba_seller_count": fba_seller_count,
|
| 274 |
+
"fbm_seller_count": fbm_seller_count,
|
| 275 |
+
"is_amazon_sold": is_amazon,
|
| 276 |
+
"is_fba": is_fba,
|
| 277 |
+
"is_prime": is_prime,
|
| 278 |
+
"tips": [
|
| 279 |
+
"Price within 1-2% of the current buy box price",
|
| 280 |
+
"Always use FBA — Amazon strongly favors it",
|
| 281 |
+
"Maintain seller feedback score above 95%",
|
| 282 |
+
"Keep order defect rate below 1%",
|
| 283 |
+
"Respond to customer messages within 24 hours",
|
| 284 |
+
],
|
| 285 |
+
"other_sellers": data.get("other_sellers", []),
|
| 286 |
+
"all_competitors": all_competitors,
|
| 287 |
+
"offers_source": offers_source,
|
| 288 |
+
"rotation": rotation,
|
| 289 |
+
"history_timeline": history_timeline or [],
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def persist_scrape(db: Session, product: Optional[Product], asin: str, data: dict) -> Product:
|
| 294 |
+
if not product:
|
| 295 |
+
product = Product(
|
| 296 |
+
id=str(uuid.uuid4()),
|
| 297 |
+
asin=asin,
|
| 298 |
+
title=data["title"],
|
| 299 |
+
brand=data["brand"],
|
| 300 |
+
upc=data.get("upc"),
|
| 301 |
+
category=data["category"],
|
| 302 |
+
image_url=data["image_url"],
|
| 303 |
+
amazon_url=data["amazon_url"],
|
| 304 |
+
is_prime=data["is_prime"],
|
| 305 |
+
)
|
| 306 |
+
db.add(product)
|
| 307 |
+
db.flush()
|
| 308 |
+
else:
|
| 309 |
+
product.title = data["title"]
|
| 310 |
+
product.brand = data["brand"]
|
| 311 |
+
product.upc = data.get("upc") or product.upc
|
| 312 |
+
product.category = data.get("category") or product.category
|
| 313 |
+
product.image_url = data.get("image_url") or product.image_url
|
| 314 |
+
product.is_prime = data.get("is_prime", product.is_prime)
|
| 315 |
+
|
| 316 |
+
product.seller_count = data.get("seller_count", 1)
|
| 317 |
+
product.buy_box_winner = data.get("buy_box_winner")
|
| 318 |
+
product.buy_box_price = data.get("buy_box_price")
|
| 319 |
+
product.buy_box_is_fba = data.get("buy_box_is_fba") if "buy_box_is_fba" in data else product.buy_box_is_fba
|
| 320 |
+
product.has_buy_box = data.get("has_buy_box", True)
|
| 321 |
+
product.is_amazon_sold = data.get("is_amazon_sold", False)
|
| 322 |
+
if data.get("package_weight_lbs") is not None:
|
| 323 |
+
product.package_weight_lbs = data.get("package_weight_lbs")
|
| 324 |
+
if data.get("other_sellers") is not None:
|
| 325 |
+
product.other_sellers_json = json.dumps(data["other_sellers"][:30])
|
| 326 |
+
if data.get("offers_source"):
|
| 327 |
+
product.offers_source = data.get("offers_source")
|
| 328 |
+
product.last_data_source = data.get("data_source", "live")
|
| 329 |
+
product.last_synced_at = datetime.now(timezone.utc)
|
| 330 |
+
|
| 331 |
+
history = PriceHistory(
|
| 332 |
+
id=str(uuid.uuid4()),
|
| 333 |
+
product_id=str(product.id),
|
| 334 |
+
price=data["price"],
|
| 335 |
+
bsr=data["bsr"],
|
| 336 |
+
rating=data["rating"],
|
| 337 |
+
review_count=data["review_count"],
|
| 338 |
+
in_stock=data["in_stock"],
|
| 339 |
+
)
|
| 340 |
+
db.add(history)
|
| 341 |
+
record_buy_box_snapshot(db, str(product.id), data)
|
| 342 |
+
db.commit()
|
| 343 |
+
db.refresh(product)
|
| 344 |
+
|
| 345 |
+
if data.get("price"):
|
| 346 |
+
check_alerts(db, asin, float(data["price"]), data.get("bsr"))
|
| 347 |
+
|
| 348 |
+
return product
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def build_response(db: Session, product: Product, data_source: str = "cached") -> dict:
|
| 352 |
+
history = (
|
| 353 |
+
db.query(PriceHistory)
|
| 354 |
+
.filter(PriceHistory.product_id == product.id)
|
| 355 |
+
.order_by(PriceHistory.recorded_at.desc())
|
| 356 |
+
.limit(180)
|
| 357 |
+
.all()
|
| 358 |
+
)
|
| 359 |
+
latest = history[0] if history else None
|
| 360 |
+
sales_data = estimate_monthly_sales(latest.bsr if latest else 0, product.category or "")
|
| 361 |
+
|
| 362 |
+
seller_count = product.seller_count or 1
|
| 363 |
+
opportunity_score = None
|
| 364 |
+
if latest and sales_data["monthly_units"]:
|
| 365 |
+
opportunity_score = calculate_opportunity_score(
|
| 366 |
+
bsr=latest.bsr or 0,
|
| 367 |
+
review_count=latest.review_count or 0,
|
| 368 |
+
monthly_sales=sales_data["monthly_units"],
|
| 369 |
+
seller_count=seller_count,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
price = float(latest.price) if latest and latest.price else None
|
| 373 |
+
rating = float(latest.rating) if latest and latest.rating else None
|
| 374 |
+
reviews = latest.review_count if latest else 0
|
| 375 |
+
|
| 376 |
+
bb_data = _buy_box_data_from_product(product)
|
| 377 |
+
snapshots = get_buy_box_snapshots(db, str(product.id), days=90)
|
| 378 |
+
rotation = rotation_from_snapshots(snapshots, range_days=30, fallback_data={**bb_data, "price": price})
|
| 379 |
+
history_timeline = build_buy_box_timeline(snapshots, range_days=30)
|
| 380 |
+
price_history_rows = [
|
| 381 |
+
{
|
| 382 |
+
"price": float(h.price) if h.price else None,
|
| 383 |
+
"bsr": h.bsr,
|
| 384 |
+
"rating": float(h.rating) if h.rating else None,
|
| 385 |
+
"review_count": h.review_count,
|
| 386 |
+
"recorded_at": h.recorded_at.isoformat(),
|
| 387 |
+
}
|
| 388 |
+
for h in reversed(history)
|
| 389 |
+
]
|
| 390 |
+
historical_charts = build_historical_charts_payload(
|
| 391 |
+
snapshots,
|
| 392 |
+
price_history_rows,
|
| 393 |
+
category=product.category or "default",
|
| 394 |
+
range_days=365,
|
| 395 |
+
weight_lbs=float(product.package_weight_lbs) if product.package_weight_lbs else 1.0,
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
buy_box = build_buy_box_analysis(
|
| 399 |
+
bb_data,
|
| 400 |
+
price=price or 25,
|
| 401 |
+
rating=rating or 4.0,
|
| 402 |
+
reviews=reviews or 100,
|
| 403 |
+
rotation=rotation,
|
| 404 |
+
history_timeline=history_timeline,
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
source = product.last_data_source or data_source
|
| 408 |
+
last_synced = product.last_synced_at.isoformat() if product.last_synced_at else None
|
| 409 |
+
|
| 410 |
+
return {
|
| 411 |
+
"asin": product.asin,
|
| 412 |
+
"title": product.title,
|
| 413 |
+
"brand": product.brand,
|
| 414 |
+
"upc": product.upc,
|
| 415 |
+
"category": product.category,
|
| 416 |
+
"image_url": product.image_url,
|
| 417 |
+
"amazon_url": product.amazon_url,
|
| 418 |
+
"is_prime": product.is_prime,
|
| 419 |
+
"current_price": price,
|
| 420 |
+
"current_bsr": latest.bsr if latest else None,
|
| 421 |
+
"current_rating": rating,
|
| 422 |
+
"current_review_count": latest.review_count if latest else None,
|
| 423 |
+
"in_stock": latest.in_stock if latest else None,
|
| 424 |
+
"sales_estimate_monthly": sales_data["monthly_units"],
|
| 425 |
+
"revenue_estimate_monthly": round(sales_data["monthly_units"] * price, 2) if sales_data["monthly_units"] and price else None,
|
| 426 |
+
"opportunity_score": opportunity_score,
|
| 427 |
+
"price_history": price_history_rows,
|
| 428 |
+
"buy_box": buy_box,
|
| 429 |
+
"buy_box_history": {
|
| 430 |
+
"snapshot_count": len(snapshots),
|
| 431 |
+
"source": rotation.get("source", "estimated"),
|
| 432 |
+
"timeline": history_timeline,
|
| 433 |
+
"snapshots": historical_charts.get("buy_box_price_series", []),
|
| 434 |
+
"charts": historical_charts,
|
| 435 |
+
},
|
| 436 |
+
"historical_charts": historical_charts,
|
| 437 |
+
"package_weight_lbs": float(product.package_weight_lbs) if product.package_weight_lbs else None,
|
| 438 |
+
"data_source": source,
|
| 439 |
+
"last_synced_at": last_synced,
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def fetch_and_sync_product(db: Session, asin: str, force: bool = False) -> dict:
|
| 444 |
+
product = db.query(Product).filter(Product.asin == asin).first()
|
| 445 |
+
needs_refresh = force
|
| 446 |
+
|
| 447 |
+
if not needs_refresh and product and product.last_synced_at:
|
| 448 |
+
last_synced = product.last_synced_at
|
| 449 |
+
if last_synced.tzinfo is None:
|
| 450 |
+
last_synced = last_synced.replace(tzinfo=timezone.utc)
|
| 451 |
+
age_hours = (datetime.now(timezone.utc) - last_synced).total_seconds() / 3600
|
| 452 |
+
needs_refresh = age_hours > 6
|
| 453 |
+
|
| 454 |
+
if not needs_refresh and product:
|
| 455 |
+
return build_response(db, product, "cached")
|
| 456 |
+
|
| 457 |
+
data = scrape_amazon_product(asin)
|
| 458 |
+
if not data:
|
| 459 |
+
if product:
|
| 460 |
+
resp = build_response(db, product, "cached")
|
| 461 |
+
resp["data_source"] = product.last_data_source or "cached"
|
| 462 |
+
return resp
|
| 463 |
+
return None
|
| 464 |
+
|
| 465 |
+
try:
|
| 466 |
+
product = persist_scrape(db, product, asin, data)
|
| 467 |
+
return build_response(db, product, data.get("data_source", "live"))
|
| 468 |
+
except Exception as e:
|
| 469 |
+
db.rollback()
|
| 470 |
+
print(f"[product_service] DB error: {e}")
|
| 471 |
+
sales_data = estimate_monthly_sales(data.get("bsr") or 0, data.get("category") or "")
|
| 472 |
+
opp = calculate_opportunity_score(
|
| 473 |
+
bsr=data.get("bsr") or 0,
|
| 474 |
+
review_count=data.get("review_count") or 0,
|
| 475 |
+
monthly_sales=sales_data["monthly_units"],
|
| 476 |
+
seller_count=data.get("seller_count", 1),
|
| 477 |
+
) if sales_data["monthly_units"] else None
|
| 478 |
+
buy_box = build_buy_box_analysis(
|
| 479 |
+
data,
|
| 480 |
+
price=data.get("price") or 25,
|
| 481 |
+
rating=data.get("rating") or 4.0,
|
| 482 |
+
reviews=data.get("review_count") or 100,
|
| 483 |
+
)
|
| 484 |
+
return {
|
| 485 |
+
"asin": asin,
|
| 486 |
+
"title": data["title"],
|
| 487 |
+
"brand": data["brand"],
|
| 488 |
+
"category": data["category"],
|
| 489 |
+
"image_url": data["image_url"],
|
| 490 |
+
"amazon_url": data["amazon_url"],
|
| 491 |
+
"is_prime": data.get("is_prime"),
|
| 492 |
+
"current_price": data["price"],
|
| 493 |
+
"current_bsr": data["bsr"],
|
| 494 |
+
"current_rating": data["rating"],
|
| 495 |
+
"current_review_count": data["review_count"],
|
| 496 |
+
"in_stock": data["in_stock"],
|
| 497 |
+
"sales_estimate_monthly": sales_data["monthly_units"],
|
| 498 |
+
"revenue_estimate_monthly": round(sales_data["monthly_units"] * (data["price"] or 0), 2) if sales_data["monthly_units"] and data["price"] else None,
|
| 499 |
+
"opportunity_score": opp,
|
| 500 |
+
"price_history": [],
|
| 501 |
+
"buy_box": buy_box,
|
| 502 |
+
"data_source": data.get("data_source", "live"),
|
| 503 |
+
"last_synced_at": datetime.now(timezone.utc).isoformat(),
|
| 504 |
+
}
|
app/services/scheduler.py
CHANGED
|
@@ -1,42 +1,42 @@
|
|
| 1 |
-
"""Background refresh of tracked products."""
|
| 2 |
-
import threading
|
| 3 |
-
import time
|
| 4 |
-
from app.database import SessionLocal
|
| 5 |
-
from app.models.product import TrackedProduct, Product
|
| 6 |
-
from app.services.product_service import fetch_and_sync_product
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def refresh_all_tracked():
|
| 10 |
-
db = SessionLocal()
|
| 11 |
-
try:
|
| 12 |
-
asins = set()
|
| 13 |
-
try:
|
| 14 |
-
tracked = db.query(TrackedProduct).all()
|
| 15 |
-
except Exception as e:
|
| 16 |
-
print(f"[scheduler] DB query failed: {e}")
|
| 17 |
-
return
|
| 18 |
-
for row in tracked:
|
| 19 |
-
product = db.query(Product).filter(Product.id == row.product_id).first()
|
| 20 |
-
if product:
|
| 21 |
-
asins.add(product.asin)
|
| 22 |
-
for asin in asins:
|
| 23 |
-
try:
|
| 24 |
-
fetch_and_sync_product(db, asin, force=True)
|
| 25 |
-
print(f"[scheduler] Refreshed {asin}")
|
| 26 |
-
except Exception as e:
|
| 27 |
-
print(f"[scheduler] Failed {asin}: {e}")
|
| 28 |
-
finally:
|
| 29 |
-
db.close()
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def _loop(interval_hours: float = 6.0):
|
| 33 |
-
while True:
|
| 34 |
-
time.sleep(interval_hours * 3600)
|
| 35 |
-
print("[scheduler] Running tracked product refresh...")
|
| 36 |
-
refresh_all_tracked()
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def start_background_scheduler(interval_hours: float = 6.0):
|
| 40 |
-
thread = threading.Thread(target=_loop, args=(interval_hours,), daemon=True)
|
| 41 |
-
thread.start()
|
| 42 |
-
print(f"[scheduler] Background refresh every {interval_hours}h started")
|
|
|
|
| 1 |
+
"""Background refresh of tracked products."""
|
| 2 |
+
import threading
|
| 3 |
+
import time
|
| 4 |
+
from app.database import SessionLocal
|
| 5 |
+
from app.models.product import TrackedProduct, Product
|
| 6 |
+
from app.services.product_service import fetch_and_sync_product
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def refresh_all_tracked():
|
| 10 |
+
db = SessionLocal()
|
| 11 |
+
try:
|
| 12 |
+
asins = set()
|
| 13 |
+
try:
|
| 14 |
+
tracked = db.query(TrackedProduct).all()
|
| 15 |
+
except Exception as e:
|
| 16 |
+
print(f"[scheduler] DB query failed: {e}")
|
| 17 |
+
return
|
| 18 |
+
for row in tracked:
|
| 19 |
+
product = db.query(Product).filter(Product.id == row.product_id).first()
|
| 20 |
+
if product:
|
| 21 |
+
asins.add(product.asin)
|
| 22 |
+
for asin in asins:
|
| 23 |
+
try:
|
| 24 |
+
fetch_and_sync_product(db, asin, force=True)
|
| 25 |
+
print(f"[scheduler] Refreshed {asin}")
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"[scheduler] Failed {asin}: {e}")
|
| 28 |
+
finally:
|
| 29 |
+
db.close()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _loop(interval_hours: float = 6.0):
|
| 33 |
+
while True:
|
| 34 |
+
time.sleep(interval_hours * 3600)
|
| 35 |
+
print("[scheduler] Running tracked product refresh...")
|
| 36 |
+
refresh_all_tracked()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def start_background_scheduler(interval_hours: float = 6.0):
|
| 40 |
+
thread = threading.Thread(target=_loop, args=(interval_hours,), daemon=True)
|
| 41 |
+
thread.start()
|
| 42 |
+
print(f"[scheduler] Background refresh every {interval_hours}h started")
|
app/services/supplier_service.py
CHANGED
|
@@ -1,153 +1,153 @@
|
|
| 1 |
-
"""Check wholesale marketplaces for listings matching a product title."""
|
| 2 |
-
import re
|
| 3 |
-
import urllib.parse
|
| 4 |
-
from typing import Optional
|
| 5 |
-
|
| 6 |
-
import requests
|
| 7 |
-
from bs4 import BeautifulSoup
|
| 8 |
-
|
| 9 |
-
from app.config import settings
|
| 10 |
-
|
| 11 |
-
SUPPLIERS = [
|
| 12 |
-
{"id": "ebay", "name": "eBay", "accent": "#E53238"},
|
| 13 |
-
{"id": "alibaba", "name": "Alibaba", "accent": "#FF6A00"},
|
| 14 |
-
{"id": "walmart", "name": "Walmart", "accent": "#0071CE"},
|
| 15 |
-
{"id": "bigw", "name": "Big W", "accent": "#D32323"},
|
| 16 |
-
]
|
| 17 |
-
|
| 18 |
-
STOP_WORDS = {
|
| 19 |
-
"the", "a", "an", "and", "or", "for", "with", "in", "on", "of", "to", "by",
|
| 20 |
-
"pack", "set", "new", "free", "shipping", "size", "color", "amazon", "official",
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def _fetch_html(url: str, timeout: int = 20) -> Optional[str]:
|
| 25 |
-
if settings.scraper_api_key:
|
| 26 |
-
try:
|
| 27 |
-
resp = requests.get(
|
| 28 |
-
"https://api.scraperapi.com",
|
| 29 |
-
params={"api_key": settings.scraper_api_key, "url": url},
|
| 30 |
-
timeout=timeout,
|
| 31 |
-
)
|
| 32 |
-
if resp.status_code == 200 and len(resp.text) > 400:
|
| 33 |
-
return resp.text
|
| 34 |
-
except Exception as e:
|
| 35 |
-
print(f"[suppliers] ScraperAPI error: {e}")
|
| 36 |
-
|
| 37 |
-
try:
|
| 38 |
-
resp = requests.get(
|
| 39 |
-
url,
|
| 40 |
-
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/122.0.0.0 Safari/537.36"},
|
| 41 |
-
timeout=10,
|
| 42 |
-
)
|
| 43 |
-
if resp.status_code == 200 and len(resp.text) > 400:
|
| 44 |
-
return resp.text
|
| 45 |
-
except Exception as e:
|
| 46 |
-
print(f"[suppliers] Fetch error: {e}")
|
| 47 |
-
return None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def build_search_query(title: str, brand: str = "") -> str:
|
| 51 |
-
from app.security.input_guard import strip_for_regex, MAX_QUERY_LEN
|
| 52 |
-
raw = strip_for_regex(f"{brand} {title or ''}", MAX_QUERY_LEN)
|
| 53 |
-
words = re.findall(r"[A-Za-z0-9]+", raw)
|
| 54 |
-
picked: list[str] = []
|
| 55 |
-
seen: set[str] = set()
|
| 56 |
-
for w in words:
|
| 57 |
-
low = w.lower()
|
| 58 |
-
if low in STOP_WORDS or len(w) < 2 or low in seen:
|
| 59 |
-
continue
|
| 60 |
-
seen.add(low)
|
| 61 |
-
picked.append(w)
|
| 62 |
-
if len(picked) >= 5:
|
| 63 |
-
break
|
| 64 |
-
return " ".join(picked) if picked else (brand or title or "")[:60]
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def _check_ebay(query: str) -> Optional[dict]:
|
| 68 |
-
url = f"https://www.ebay.com/sch/i.html?_nkw={urllib.parse.quote(query)}"
|
| 69 |
-
html = _fetch_html(url)
|
| 70 |
-
if not html:
|
| 71 |
-
return None
|
| 72 |
-
soup = BeautifulSoup(html, "html.parser")
|
| 73 |
-
items = [el for el in soup.select("li.s-item") if el.select_one(".s-item__title")]
|
| 74 |
-
if len(items) >= 1:
|
| 75 |
-
return {"id": "ebay", "name": "eBay", "url": url, "accent": "#E53238", "count": len(items)}
|
| 76 |
-
if "results for" in html.lower() and "0 results" not in html.lower():
|
| 77 |
-
return {"id": "ebay", "name": "eBay", "url": url, "accent": "#E53238", "count": 1}
|
| 78 |
-
return None
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def _check_alibaba(query: str) -> Optional[dict]:
|
| 82 |
-
url = f"https://www.alibaba.com/trade/search?SearchText={urllib.parse.quote(query)}"
|
| 83 |
-
html = _fetch_html(url)
|
| 84 |
-
if not html:
|
| 85 |
-
return None
|
| 86 |
-
low = html.lower()
|
| 87 |
-
if any(x in low for x in ("organic-list-offer", "search-card", "product-item", "gallery-offer")):
|
| 88 |
-
return {"id": "alibaba", "name": "Alibaba", "url": url, "accent": "#FF6A00", "count": 1}
|
| 89 |
-
if "no matching results" in low or "0 product" in low:
|
| 90 |
-
return None
|
| 91 |
-
return None
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def _check_walmart(query: str) -> Optional[dict]:
|
| 95 |
-
url = f"https://www.walmart.com/search?q={urllib.parse.quote(query)}"
|
| 96 |
-
html = _fetch_html(url)
|
| 97 |
-
if not html:
|
| 98 |
-
return None
|
| 99 |
-
soup = BeautifulSoup(html, "html.parser")
|
| 100 |
-
if soup.select('[data-testid="list-view"], [data-item-id], div[data-automation-id="product-title"]'):
|
| 101 |
-
return {"id": "walmart", "name": "Walmart", "url": url, "accent": "#0071CE", "count": 1}
|
| 102 |
-
low = html.lower()
|
| 103 |
-
if "results for" in low and "no results" not in low:
|
| 104 |
-
return {"id": "walmart", "name": "Walmart", "url": url, "accent": "#0071CE", "count": 1}
|
| 105 |
-
return None
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
def _check_bigw(query: str) -> Optional[dict]:
|
| 109 |
-
url = f"https://www.bigw.com.au/search?q={urllib.parse.quote(query)}"
|
| 110 |
-
html = _fetch_html(url)
|
| 111 |
-
if not html:
|
| 112 |
-
return None
|
| 113 |
-
low = html.lower()
|
| 114 |
-
if any(x in low for x in ("product-tile", "search-results", "article[data-testid", "product-card")):
|
| 115 |
-
return {"id": "bigw", "name": "Big W", "url": url, "accent": "#D32323", "count": 1}
|
| 116 |
-
if "no results" in low or "0 results" in low:
|
| 117 |
-
return None
|
| 118 |
-
return None
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
CHECKERS = {
|
| 122 |
-
"ebay": _check_ebay,
|
| 123 |
-
"alibaba": _check_alibaba,
|
| 124 |
-
"walmart": _check_walmart,
|
| 125 |
-
"bigw": _check_bigw,
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def find_suppliers(title: str, brand: str = "", asin: str = "") -> dict:
|
| 130 |
-
query = build_search_query(title, brand)
|
| 131 |
-
found = []
|
| 132 |
-
for sid, checker in CHECKERS.items():
|
| 133 |
-
try:
|
| 134 |
-
hit = checker(query)
|
| 135 |
-
if hit:
|
| 136 |
-
found.append(hit)
|
| 137 |
-
except Exception as e:
|
| 138 |
-
print(f"[suppliers] {sid} check failed: {e}")
|
| 139 |
-
|
| 140 |
-
if not found and settings.allow_mock_data and title:
|
| 141 |
-
mock_ids = ["ebay", "alibaba"] if len(query.split()) >= 2 else ["ebay"]
|
| 142 |
-
for sid in mock_ids:
|
| 143 |
-
meta = next(s for s in SUPPLIERS if s["id"] == sid)
|
| 144 |
-
q = urllib.parse.quote(query)
|
| 145 |
-
urls = {
|
| 146 |
-
"ebay": f"https://www.ebay.com/sch/i.html?_nkw={q}",
|
| 147 |
-
"alibaba": f"https://www.alibaba.com/trade/search?SearchText={q}",
|
| 148 |
-
"walmart": f"https://www.walmart.com/search?q={q}",
|
| 149 |
-
"bigw": f"https://www.bigw.com.au/search?q={q}",
|
| 150 |
-
}
|
| 151 |
-
found.append({**meta, "url": urls[sid], "count": 1, "estimated": True})
|
| 152 |
-
|
| 153 |
-
return {"query": query, "asin": asin, "suppliers": found}
|
|
|
|
| 1 |
+
"""Check wholesale marketplaces for listings matching a product title."""
|
| 2 |
+
import re
|
| 3 |
+
import urllib.parse
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
from bs4 import BeautifulSoup
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
|
| 11 |
+
SUPPLIERS = [
|
| 12 |
+
{"id": "ebay", "name": "eBay", "accent": "#E53238"},
|
| 13 |
+
{"id": "alibaba", "name": "Alibaba", "accent": "#FF6A00"},
|
| 14 |
+
{"id": "walmart", "name": "Walmart", "accent": "#0071CE"},
|
| 15 |
+
{"id": "bigw", "name": "Big W", "accent": "#D32323"},
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
STOP_WORDS = {
|
| 19 |
+
"the", "a", "an", "and", "or", "for", "with", "in", "on", "of", "to", "by",
|
| 20 |
+
"pack", "set", "new", "free", "shipping", "size", "color", "amazon", "official",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _fetch_html(url: str, timeout: int = 20) -> Optional[str]:
|
| 25 |
+
if settings.scraper_api_key:
|
| 26 |
+
try:
|
| 27 |
+
resp = requests.get(
|
| 28 |
+
"https://api.scraperapi.com",
|
| 29 |
+
params={"api_key": settings.scraper_api_key, "url": url},
|
| 30 |
+
timeout=timeout,
|
| 31 |
+
)
|
| 32 |
+
if resp.status_code == 200 and len(resp.text) > 400:
|
| 33 |
+
return resp.text
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"[suppliers] ScraperAPI error: {e}")
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
resp = requests.get(
|
| 39 |
+
url,
|
| 40 |
+
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/122.0.0.0 Safari/537.36"},
|
| 41 |
+
timeout=10,
|
| 42 |
+
)
|
| 43 |
+
if resp.status_code == 200 and len(resp.text) > 400:
|
| 44 |
+
return resp.text
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"[suppliers] Fetch error: {e}")
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def build_search_query(title: str, brand: str = "") -> str:
|
| 51 |
+
from app.security.input_guard import strip_for_regex, MAX_QUERY_LEN
|
| 52 |
+
raw = strip_for_regex(f"{brand} {title or ''}", MAX_QUERY_LEN)
|
| 53 |
+
words = re.findall(r"[A-Za-z0-9]+", raw)
|
| 54 |
+
picked: list[str] = []
|
| 55 |
+
seen: set[str] = set()
|
| 56 |
+
for w in words:
|
| 57 |
+
low = w.lower()
|
| 58 |
+
if low in STOP_WORDS or len(w) < 2 or low in seen:
|
| 59 |
+
continue
|
| 60 |
+
seen.add(low)
|
| 61 |
+
picked.append(w)
|
| 62 |
+
if len(picked) >= 5:
|
| 63 |
+
break
|
| 64 |
+
return " ".join(picked) if picked else (brand or title or "")[:60]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _check_ebay(query: str) -> Optional[dict]:
|
| 68 |
+
url = f"https://www.ebay.com/sch/i.html?_nkw={urllib.parse.quote(query)}"
|
| 69 |
+
html = _fetch_html(url)
|
| 70 |
+
if not html:
|
| 71 |
+
return None
|
| 72 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 73 |
+
items = [el for el in soup.select("li.s-item") if el.select_one(".s-item__title")]
|
| 74 |
+
if len(items) >= 1:
|
| 75 |
+
return {"id": "ebay", "name": "eBay", "url": url, "accent": "#E53238", "count": len(items)}
|
| 76 |
+
if "results for" in html.lower() and "0 results" not in html.lower():
|
| 77 |
+
return {"id": "ebay", "name": "eBay", "url": url, "accent": "#E53238", "count": 1}
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _check_alibaba(query: str) -> Optional[dict]:
|
| 82 |
+
url = f"https://www.alibaba.com/trade/search?SearchText={urllib.parse.quote(query)}"
|
| 83 |
+
html = _fetch_html(url)
|
| 84 |
+
if not html:
|
| 85 |
+
return None
|
| 86 |
+
low = html.lower()
|
| 87 |
+
if any(x in low for x in ("organic-list-offer", "search-card", "product-item", "gallery-offer")):
|
| 88 |
+
return {"id": "alibaba", "name": "Alibaba", "url": url, "accent": "#FF6A00", "count": 1}
|
| 89 |
+
if "no matching results" in low or "0 product" in low:
|
| 90 |
+
return None
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _check_walmart(query: str) -> Optional[dict]:
|
| 95 |
+
url = f"https://www.walmart.com/search?q={urllib.parse.quote(query)}"
|
| 96 |
+
html = _fetch_html(url)
|
| 97 |
+
if not html:
|
| 98 |
+
return None
|
| 99 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 100 |
+
if soup.select('[data-testid="list-view"], [data-item-id], div[data-automation-id="product-title"]'):
|
| 101 |
+
return {"id": "walmart", "name": "Walmart", "url": url, "accent": "#0071CE", "count": 1}
|
| 102 |
+
low = html.lower()
|
| 103 |
+
if "results for" in low and "no results" not in low:
|
| 104 |
+
return {"id": "walmart", "name": "Walmart", "url": url, "accent": "#0071CE", "count": 1}
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _check_bigw(query: str) -> Optional[dict]:
|
| 109 |
+
url = f"https://www.bigw.com.au/search?q={urllib.parse.quote(query)}"
|
| 110 |
+
html = _fetch_html(url)
|
| 111 |
+
if not html:
|
| 112 |
+
return None
|
| 113 |
+
low = html.lower()
|
| 114 |
+
if any(x in low for x in ("product-tile", "search-results", "article[data-testid", "product-card")):
|
| 115 |
+
return {"id": "bigw", "name": "Big W", "url": url, "accent": "#D32323", "count": 1}
|
| 116 |
+
if "no results" in low or "0 results" in low:
|
| 117 |
+
return None
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
CHECKERS = {
|
| 122 |
+
"ebay": _check_ebay,
|
| 123 |
+
"alibaba": _check_alibaba,
|
| 124 |
+
"walmart": _check_walmart,
|
| 125 |
+
"bigw": _check_bigw,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def find_suppliers(title: str, brand: str = "", asin: str = "") -> dict:
|
| 130 |
+
query = build_search_query(title, brand)
|
| 131 |
+
found = []
|
| 132 |
+
for sid, checker in CHECKERS.items():
|
| 133 |
+
try:
|
| 134 |
+
hit = checker(query)
|
| 135 |
+
if hit:
|
| 136 |
+
found.append(hit)
|
| 137 |
+
except Exception as e:
|
| 138 |
+
print(f"[suppliers] {sid} check failed: {e}")
|
| 139 |
+
|
| 140 |
+
if not found and settings.allow_mock_data and title:
|
| 141 |
+
mock_ids = ["ebay", "alibaba"] if len(query.split()) >= 2 else ["ebay"]
|
| 142 |
+
for sid in mock_ids:
|
| 143 |
+
meta = next(s for s in SUPPLIERS if s["id"] == sid)
|
| 144 |
+
q = urllib.parse.quote(query)
|
| 145 |
+
urls = {
|
| 146 |
+
"ebay": f"https://www.ebay.com/sch/i.html?_nkw={q}",
|
| 147 |
+
"alibaba": f"https://www.alibaba.com/trade/search?SearchText={q}",
|
| 148 |
+
"walmart": f"https://www.walmart.com/search?q={q}",
|
| 149 |
+
"bigw": f"https://www.bigw.com.au/search?q={q}",
|
| 150 |
+
}
|
| 151 |
+
found.append({**meta, "url": urls[sid], "count": 1, "estimated": True})
|
| 152 |
+
|
| 153 |
+
return {"query": query, "asin": asin, "suppliers": found}
|
app/utils/validators.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
"""Input validation: SSTI, SQL/NoSQL injection probes, ReDoS-safe string handling."""
|
| 2 |
-
import re
|
| 3 |
-
from fastapi import HTTPException
|
| 4 |
-
|
| 5 |
-
from app.security.input_guard import normalize_asin_safe
|
| 6 |
-
|
| 7 |
-
ASIN_PATTERN = re.compile(r"^[A-Z0-9]{10}$")
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
def normalize_asin(asin: str) -> str:
|
| 11 |
-
return normalize_asin_safe(asin)
|
|
|
|
| 1 |
+
"""Input validation: SSTI, SQL/NoSQL injection probes, ReDoS-safe string handling."""
|
| 2 |
+
import re
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
|
| 5 |
+
from app.security.input_guard import normalize_asin_safe
|
| 6 |
+
|
| 7 |
+
ASIN_PATTERN = re.compile(r"^[A-Z0-9]{10}$")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def normalize_asin(asin: str) -> str:
|
| 11 |
+
return normalize_asin_safe(asin)
|
requirements.txt
CHANGED
|
@@ -1,20 +1,20 @@
|
|
| 1 |
-
fastapi>=0.109.0
|
| 2 |
-
uvicorn[standard]>=0.27.0
|
| 3 |
-
sqlalchemy>=2.0.25
|
| 4 |
-
pydantic[email]>=2.5.0
|
| 5 |
-
pydantic-settings>=2.0.0
|
| 6 |
-
email-validator>=2.0.0
|
| 7 |
-
python-multipart
|
| 8 |
-
python-dotenv
|
| 9 |
-
bcrypt>=4.0.0
|
| 10 |
-
python-jose[cryptography]
|
| 11 |
-
requests
|
| 12 |
-
beautifulsoup4
|
| 13 |
-
lxml
|
| 14 |
-
psycopg2-binary
|
| 15 |
-
google-generativeai>=0.8.0
|
| 16 |
-
openai>=1.0.0
|
| 17 |
-
anthropic>=0.25.0
|
| 18 |
-
httpx
|
| 19 |
-
numpy>=1.26.0
|
| 20 |
scikit-learn>=1.4.0
|
|
|
|
| 1 |
+
fastapi>=0.109.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
sqlalchemy>=2.0.25
|
| 4 |
+
pydantic[email]>=2.5.0
|
| 5 |
+
pydantic-settings>=2.0.0
|
| 6 |
+
email-validator>=2.0.0
|
| 7 |
+
python-multipart
|
| 8 |
+
python-dotenv
|
| 9 |
+
bcrypt>=4.0.0
|
| 10 |
+
python-jose[cryptography]
|
| 11 |
+
requests
|
| 12 |
+
beautifulsoup4
|
| 13 |
+
lxml
|
| 14 |
+
psycopg2-binary
|
| 15 |
+
google-generativeai>=0.8.0
|
| 16 |
+
openai>=1.0.0
|
| 17 |
+
anthropic>=0.25.0
|
| 18 |
+
httpx
|
| 19 |
+
numpy>=1.26.0
|
| 20 |
scikit-learn>=1.4.0
|