Shoaib898 commited on
Commit
5aa2bd9
·
verified ·
1 Parent(s): d69319f

Deploy Rankora API: buy box competitors, offers persistence, scraper fixes

Browse files
Files changed (47) hide show
  1. README.md +13 -13
  2. app/database.py +78 -74
  3. app/dependencies.py +51 -51
  4. app/models/__init__.py +2 -2
  5. app/models/product.py +66 -64
  6. app/models/tracking.py +17 -17
  7. app/models/user.py +21 -21
  8. app/routers/advanced_router.py +152 -152
  9. app/routers/analysis.py +103 -103
  10. app/routers/auth.py +97 -97
  11. app/routers/competitors.py +45 -45
  12. app/routers/keywords.py +30 -30
  13. app/routers/ml_router.py +276 -276
  14. app/routers/products.py +233 -233
  15. app/routers/profit.py +176 -176
  16. app/schemas/user.py +31 -31
  17. app/security/__init__.py +1 -1
  18. app/security/input_guard.py +83 -83
  19. app/security/middleware.py +67 -67
  20. app/security/rate_limit.py +70 -70
  21. app/security/secrets.py +44 -44
  22. app/security/token_revocation.py +38 -38
  23. app/services/amazon/competitor_service.py +137 -137
  24. app/services/amazon/keyword_service.py +131 -131
  25. app/services/amazon/offers_scraper.py +204 -158
  26. app/services/amazon/product_scraper.py +416 -416
  27. app/services/amazon/sales_estimator.py +73 -73
  28. app/services/amazon/scraper_utils.py +55 -55
  29. app/services/analytics/advanced_services.py +359 -359
  30. app/services/analytics/ai_analyzer.py +188 -188
  31. app/services/analytics/amazon_fees.py +227 -227
  32. app/services/analytics/buy_box_history.py +563 -563
  33. app/services/analytics/buy_box_rotation.py +153 -153
  34. app/services/analytics/profit_calculator.py +360 -360
  35. app/services/analytics/tracking_service.py +72 -72
  36. app/services/llm_service.py +198 -198
  37. app/services/ml/demand_forecaster.py +232 -232
  38. app/services/ml/fake_review_detector.py +286 -286
  39. app/services/ml/ml_engine.py +509 -509
  40. app/services/ml/niche_scorer.py +282 -282
  41. app/services/ml/price_predictor.py +239 -239
  42. app/services/ml/sklearn_engine.py +312 -312
  43. app/services/product_service.py +504 -418
  44. app/services/scheduler.py +42 -42
  45. app/services/supplier_service.py +153 -153
  46. app/utils/validators.py +11 -11
  47. 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
- pg_product_alters = [
36
- "ALTER TABLE products ADD COLUMN IF NOT EXISTS upc VARCHAR(20)",
37
- "ALTER TABLE products ADD COLUMN IF NOT EXISTS has_buy_box BOOLEAN DEFAULT TRUE",
38
- "ALTER TABLE products ADD COLUMN IF NOT EXISTS package_weight_lbs NUMERIC(8,3)",
39
- ]
40
- is_pg = DATABASE_URL.startswith("postgresql")
41
- with engine.connect() as conn:
42
- for sql in user_alters:
43
- try:
44
- if is_pg:
45
- conn.execute(text(
46
- "ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER DEFAULT 0"
47
- ))
48
- else:
49
- conn.execute(text(sql))
50
- conn.commit()
51
- except Exception:
52
- pass
53
- if DATABASE_URL.startswith("sqlite"):
54
- for sql in sqlite_product_alters:
55
- try:
56
- conn.execute(text(sql))
57
- conn.commit()
58
- except Exception:
59
- pass
60
- if is_pg:
61
- for sql in pg_product_alters:
62
- try:
63
- conn.execute(text(sql))
64
- conn.commit()
65
- except Exception:
66
- pass
67
-
68
-
69
- def get_db():
70
- db = SessionLocal()
71
- try:
72
- yield db
73
- finally:
74
- db.close()
 
 
 
 
 
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
- last_data_source = Column(String(20), default="cached")
29
- last_synced_at = Column(DateTime(timezone=True), nullable=True)
30
- created_at = Column(DateTime(timezone=True), server_default=func.now())
31
-
32
- class PriceHistory(Base):
33
- __tablename__ = "price_history"
34
- __table_args__ = {"extend_existing": True}
35
- id = Column(String(36), primary_key=True, default=gen_uuid)
36
- product_id = Column(String(36), nullable=False)
37
- price = Column(Numeric(10, 2), nullable=True)
38
- bsr = Column(Integer, nullable=True)
39
- rating = Column(Numeric(3, 2), nullable=True)
40
- review_count = Column(Integer, nullable=True)
41
- in_stock = Column(Boolean, default=True)
42
- recorded_at = Column(DateTime(timezone=True), server_default=func.now())
43
-
44
- class TrackedProduct(Base):
45
- __tablename__ = "tracked_products"
46
- __table_args__ = {"extend_existing": True}
47
- id = Column(String(36), primary_key=True, default=gen_uuid)
48
- user_id = Column(String(36), nullable=False)
49
- product_id = Column(String(36), nullable=False)
50
- tracked_at = Column(DateTime(timezone=True), server_default=func.now())
51
-
52
-
53
- class BuyBoxSnapshot(Base):
54
- """Point-in-time Buy Box winner captured on each product refresh."""
55
- __tablename__ = "buy_box_snapshots"
56
- __table_args__ = {"extend_existing": True}
57
- id = Column(String(36), primary_key=True, default=gen_uuid)
58
- product_id = Column(String(36), nullable=False, index=True)
59
- winner = Column(String(255), nullable=True)
60
- price = Column(Numeric(10, 2), nullable=True)
61
- is_fba = Column(Boolean, nullable=True)
62
- is_amazon = Column(Boolean, default=False)
63
- seller_count = Column(Integer, nullable=True)
64
- has_buy_box = Column(Boolean, default=True)
 
 
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.services.amazon.scraper_utils import get_headers, normalize_price, parse_int, parse_number
11
-
12
-
13
- def _parse_offer_row(offer_el) -> Optional[dict]:
14
- """Parse a single offer block from AOD or offer-listing HTML."""
15
- text = offer_el.get_text(" ", strip=True)
16
- if not text or len(text) < 3:
17
- return None
18
-
19
- name = None
20
- for sel in (
21
- offer_el.select_one(".a-size-small.a-color-base"),
22
- offer_el.select_one("#aod-offer-soldBy a"),
23
- offer_el.select_one(".olpSellerName"),
24
- offer_el.select_one("h3.olpSellerName"),
25
- offer_el.find("a", href=re.compile(r"seller=")),
26
- ):
27
- if sel:
28
- name = sel.get_text(strip=True)
29
- if name and len(name) > 1:
30
- break
31
-
32
- if not name:
33
- sold_match = re.search(r"(?:Sold by|from)\s+([^·|]+)", text, re.I)
34
- if sold_match:
35
- name = sold_match.group(1).strip()[:80]
36
-
37
- price = None
38
- for price_el in offer_el.select(".a-price .a-offscreen, .a-color-price, .olpOfferPrice"):
39
- price = parse_number(price_el.get_text())
40
- if price:
41
- break
42
- if price is None:
43
- pm = re.search(r"\$\s*([\d,]+\.?\d*)", text)
44
- if pm:
45
- price = parse_number(pm.group(1))
46
-
47
- is_fba = bool(
48
- offer_el.find(string=re.compile(r"Fulfilled by Amazon|FBA", re.I))
49
- or offer_el.select_one(".a-icon-prime")
50
- or "Prime" in text and "Fulfilled" in text
51
- )
52
- is_fbm = bool(offer_el.find(string=re.compile(r"Ships from.*Sold by", re.I))) and not is_fba
53
-
54
- if not name:
55
- return None
56
-
57
- return {
58
- "name": name[:80],
59
- "price": normalize_price(price) if price else None,
60
- "is_fba": True if is_fba else (False if is_fbm else None),
61
- "is_prime": bool(offer_el.select_one(".a-icon-prime")),
62
- "rating": None,
63
- }
64
-
65
-
66
- def scrape_offers(asin: str, timeout: int = 25) -> Tuple[List[dict], int, int, int]:
67
- """
68
- Fetch real competing offers for an ASIN.
69
- Returns: (offers, seller_count, fba_count, fbm_count)
70
- """
71
- asin = asin.upper().strip()
72
- offers: List[dict] = []
73
- seen_names: set = set()
74
-
75
- urls = [
76
- f"https://www.amazon.com/gp/aod/ajax/ref=auto_load_aod?asin={asin}",
77
- f"https://www.amazon.com/gp/product/ajax/aodAjaxMain/ref=auto_load_aod?asin={asin}",
78
- f"https://www.amazon.com/gp/offer-listing/{asin}/ref=olp_tab_all",
79
- ]
80
-
81
- for url in urls:
82
- try:
83
- resp = requests.get(url, headers=get_headers(), timeout=timeout)
84
- if resp.status_code != 200 or len(resp.text) < 200:
85
- continue
86
- soup = BeautifulSoup(resp.text, "html.parser")
87
-
88
- rows = (
89
- soup.select("#aod-offer-list #aod-offer")
90
- or soup.select("#aod-offer")
91
- or soup.select(".a-section.a-spacing-none.a-padding-base.olpOffer")
92
- or soup.select("#olpOfferList .a-row")
93
- )
94
-
95
- for row in rows[:20]:
96
- offer = _parse_offer_row(row)
97
- if not offer:
98
- continue
99
- key = offer["name"].lower()
100
- if key in seen_names:
101
- continue
102
- seen_names.add(key)
103
- offers.append(offer)
104
-
105
- if offers:
106
- break
107
- except Exception as e:
108
- print(f"[offers_scraper] {url}: {e}")
109
- continue
110
-
111
- fba_count = sum(1 for o in offers if o.get("is_fba") is True)
112
- fbm_count = sum(1 for o in offers if o.get("is_fba") is False)
113
- unknown = sum(1 for o in offers if o.get("is_fba") is None)
114
- seller_count = max(len(offers), 1)
115
-
116
- return offers, seller_count, fba_count, fbm_count
117
-
118
-
119
- def enrich_product_offers(data: dict) -> dict:
120
- """Attach scraped offers to product payload; never synthesize fake sellers."""
121
- asin = data.get("asin")
122
- if not asin:
123
- return data
124
-
125
- offers, count, fba, fbm = scrape_offers(asin)
126
- if offers:
127
- data["other_sellers"] = offers
128
- data["seller_count"] = max(data.get("seller_count") or 1, count)
129
- data["fba_seller_count"] = fba if fba > 0 else None
130
- data["fbm_seller_count"] = fbm if fbm > 0 else None
131
- data["offers_source"] = "live_aod"
132
- else:
133
- data["other_sellers"] = data.get("other_sellers") or []
134
- data["offers_source"] = "product_page_only"
135
- return data
136
-
137
-
138
- def extract_package_weight(soup: BeautifulSoup) -> Optional[float]:
139
- """Extract item/package weight in pounds from product detail bullets."""
140
- patterns = [
141
- r"([\d.]+)\s*pounds",
142
- r"([\d.]+)\s*lbs",
143
- r"([\d.]+)\s*lb\b",
144
- r"([\d.]+)\s*ounces",
145
- r"([\d.]+)\s*oz\b",
146
- ]
147
- for block in soup.select("#detailBullets_feature_div li, #productDetails_detailBullets_sections1 tr, .prodDetSectionEntry"):
148
- text = block.get_text(" ", strip=True).lower()
149
- if "weight" not in text and "shipping weight" not in text and "item weight" not in text:
150
- continue
151
- for pat in patterns:
152
- m = re.search(pat, text)
153
- if m:
154
- val = float(m.group(1))
155
- if "ounce" in text or " oz" in text:
156
- return round(val / 16, 3)
157
- return round(val, 3)
158
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 uuid
3
- from datetime import datetime, timezone
4
- from typing import Optional
5
-
6
- from sqlalchemy.orm import Session
7
-
8
- from app.models.product import Product, PriceHistory
9
- from app.services.amazon.product_scraper import scrape_amazon_product
10
- from app.services.amazon.sales_estimator import estimate_monthly_sales, calculate_opportunity_score
11
- from app.services.analytics.tracking_service import check_alerts
12
- from app.services.analytics.buy_box_rotation import estimate_buy_box_rotation
13
- from app.services.analytics.buy_box_history import (
14
- record_buy_box_snapshot,
15
- get_buy_box_snapshots,
16
- rotation_from_snapshots,
17
- build_buy_box_timeline,
18
- build_historical_charts_payload,
19
- )
20
-
21
-
22
- def _buy_box_data_from_product(product: Product) -> dict:
23
- has_bb = product.has_buy_box if product.has_buy_box is not None else True
24
- is_fba = product.buy_box_is_fba
25
- seller_count = product.seller_count or 1
26
- fba_sellers = 0
27
- fbm_sellers = 0
28
- if is_fba is True:
29
- fba_sellers = max(1, seller_count - 1) if seller_count > 1 else 1
30
- fbm_sellers = max(0, seller_count - fba_sellers)
31
- elif is_fba is False:
32
- fbm_sellers = max(1, seller_count)
33
- fba_sellers = max(0, seller_count - fbm_sellers)
34
- return {
35
- "buy_box_winner": product.buy_box_winner,
36
- "buy_box_price": float(product.buy_box_price) if product.buy_box_price else None,
37
- "buy_box_is_fba": is_fba,
38
- "buy_box_is_prime": product.is_prime,
39
- "is_amazon_sold": product.is_amazon_sold or False,
40
- "seller_count": seller_count,
41
- "fba_seller_count": fba_sellers,
42
- "fbm_seller_count": fbm_sellers,
43
- "has_buy_box": has_bb,
44
- "other_sellers": [],
45
- "offers_source": product.last_data_source,
46
- }
47
-
48
-
49
- def build_buy_box_analysis(
50
- data: dict,
51
- price: float,
52
- rating: float,
53
- reviews: int,
54
- rotation: Optional[dict] = None,
55
- history_timeline: Optional[list] = None,
56
- ) -> dict:
57
- """Build complete buy box analysis from scraped + calculated data."""
58
- buy_box_winner = data.get("buy_box_winner") or "Unknown"
59
- buy_box_price = data.get("buy_box_price") or price
60
- is_fba = data.get("buy_box_is_fba")
61
- is_prime = data.get("buy_box_is_prime", True)
62
- is_amazon = data.get("is_amazon_sold", False)
63
- seller_count = data.get("seller_count", 1)
64
- has_buy_box = data.get("has_buy_box", True)
65
- fba_seller_count = data.get("fba_seller_count")
66
- fbm_seller_count = data.get("fbm_seller_count")
67
- if fba_seller_count is None and fbm_seller_count is None:
68
- if is_fba is True:
69
- fba_seller_count = max(1, seller_count)
70
- fbm_seller_count = max(0, seller_count - fba_seller_count)
71
- elif is_fba is False:
72
- fbm_seller_count = max(1, seller_count)
73
- fba_seller_count = max(0, seller_count - fbm_seller_count)
74
- else:
75
- fba_seller_count = None
76
- fbm_seller_count = None
77
-
78
- score = 0
79
- factors = []
80
-
81
- if price and buy_box_price:
82
- price_diff_pct = abs(price - buy_box_price) / buy_box_price * 100
83
- if price_diff_pct <= 2:
84
- score += 25
85
- factors.append({"factor": "Price", "status": "Competitive ✓", "color": "#10B981",
86
- "detail": f"${price} is within 2% of buy box price (${buy_box_price})"})
87
- elif price_diff_pct <= 10:
88
- score += 15
89
- factors.append({"factor": "Price", "status": "Close", "color": "#F59E0B",
90
- "detail": f"${price} is {price_diff_pct:.1f}% from buy box price (${buy_box_price})"})
91
- else:
92
- score += 5
93
- factors.append({"factor": "Price", "status": "Too High", "color": "#EF4444",
94
- "detail": f"${price} is {price_diff_pct:.1f}% above buy box price — lower price to compete"})
95
- else:
96
- score += 15
97
- factors.append({"factor": "Price", "status": "Unknown", "color": "#9CA3AF", "detail": "Could not determine price gap"})
98
-
99
- if is_fba is True:
100
- score += 30
101
- factors.append({"factor": "Fulfillment", "status": "FBA ✓", "color": "#10B981",
102
- "detail": "FBA strongly favored — Amazon prefers its own fulfillment"})
103
- elif is_fba is False:
104
- score += 5
105
- factors.append({"factor": "Fulfillment", "status": "FBM ✗", "color": "#EF4444",
106
- "detail": "FBM sellers rarely win buy box — switch to FBA"})
107
- else:
108
- score += 10
109
- factors.append({"factor": "Fulfillment", "status": "Unknown", "color": "#9CA3AF",
110
- "detail": "Fulfillment type unclear refresh product data"})
111
-
112
- if is_prime:
113
- score += 20
114
- factors.append({"factor": "Prime Badge", "status": "Eligible ✓", "color": "#10B981",
115
- "detail": "Prime eligible — required for consistent buy box wins"})
116
- else:
117
- factors.append({"factor": "Prime Badge", "status": "Not Eligible ✗", "color": "#EF4444",
118
- "detail": "No Prime = very low chance of winning buy box"})
119
-
120
- if rating and rating >= 4.5:
121
- score += 15
122
- factors.append({"factor": "Seller Rating", "status": "Excellent ✓", "color": "#10B981",
123
- "detail": f"{rating}★ — Amazon heavily favors high-rated sellers"})
124
- elif rating and rating >= 4.0:
125
- score += 10
126
- factors.append({"factor": "Seller Rating", "status": "Good", "color": "#F59E0B",
127
- "detail": f"{rating}★ — acceptable but not ideal for buy box"})
128
- else:
129
- score += 3
130
- factors.append({"factor": "Seller Rating", "status": "Needs Work ✗", "color": "#EF4444",
131
- "detail": f"{rating or 'Unknown'}★ — low rating significantly hurts buy box eligibility"})
132
-
133
- if seller_count <= 2:
134
- score += 10
135
- factors.append({"factor": "Competition", "status": f"Low ({seller_count} sellers)", "color": "#10B981",
136
- "detail": "Few sellers = easier to win and keep buy box"})
137
- elif seller_count <= 5:
138
- score += 6
139
- factors.append({"factor": "Competition", "status": f"Medium ({seller_count} sellers)", "color": "#F59E0B",
140
- "detail": f"{seller_count} competing sellers — maintain competitive price"})
141
- else:
142
- score += 2
143
- factors.append({"factor": "Competition", "status": f"High ({seller_count}+ sellers)", "color": "#EF4444",
144
- "detail": f"{seller_count}+ sellers competing very hard to win consistently"})
145
-
146
- score = min(score, 100)
147
-
148
- if score >= 75:
149
- verdict, verdict_color = "Strong Buy Box Candidate", "#10B981"
150
- elif score >= 50:
151
- verdict, verdict_color = "Moderate Chance", "#F59E0B"
152
- elif score >= 25:
153
- verdict, verdict_color = "Low Chance", "#FB923C"
154
- else:
155
- verdict, verdict_color = "Unlikely to Win", "#EF4444"
156
-
157
- owner_badge = "🟡 3rd Party"
158
- owner_color = "#F59E0B"
159
- if is_amazon:
160
- owner_badge = "🔵 Amazon"
161
- owner_color = "#3B82F6"
162
- elif is_fba is True:
163
- owner_badge = "🟢 FBA Seller"
164
- owner_color = "#10B981"
165
- elif is_fba is False:
166
- owner_badge = "🟠 FBM Seller"
167
- owner_color = "#F59E0B"
168
-
169
- rotation_data = {
170
- **data,
171
- "buy_box_winner": buy_box_winner,
172
- "buy_box_price": buy_box_price,
173
- "buy_box_is_fba": is_fba,
174
- "seller_count": seller_count,
175
- "has_buy_box": has_buy_box,
176
- "price": price,
177
- }
178
- if rotation is None:
179
- rotation = estimate_buy_box_rotation(rotation_data) if has_buy_box else {"sellers": [], "eligible_fba": 0, "eligible_fbm": 0}
180
- rotation["source"] = "estimated"
181
-
182
- return {
183
- "score": score,
184
- "verdict": verdict,
185
- "verdict_color": verdict_color,
186
- "factors": factors,
187
- "has_buy_box": has_buy_box,
188
- "current_winner": buy_box_winner if has_buy_box else None,
189
- "current_winner_badge": owner_badge if has_buy_box else "No Buy Box",
190
- "current_winner_color": owner_color,
191
- "buy_box_price": buy_box_price if has_buy_box else None,
192
- "seller_count": seller_count,
193
- "fba_seller_count": fba_seller_count,
194
- "fbm_seller_count": fbm_seller_count,
195
- "is_amazon_sold": is_amazon,
196
- "is_fba": is_fba,
197
- "is_prime": is_prime,
198
- "tips": [
199
- "Price within 1-2% of the current buy box price",
200
- "Always use FBA — Amazon strongly favors it",
201
- "Maintain seller feedback score above 95%",
202
- "Keep order defect rate below 1%",
203
- "Respond to customer messages within 24 hours",
204
- ],
205
- "other_sellers": data.get("other_sellers", []),
206
- "rotation": rotation,
207
- "history_timeline": history_timeline or [],
208
- }
209
-
210
-
211
- def persist_scrape(db: Session, product: Optional[Product], asin: str, data: dict) -> Product:
212
- if not product:
213
- product = Product(
214
- id=str(uuid.uuid4()),
215
- asin=asin,
216
- title=data["title"],
217
- brand=data["brand"],
218
- upc=data.get("upc"),
219
- category=data["category"],
220
- image_url=data["image_url"],
221
- amazon_url=data["amazon_url"],
222
- is_prime=data["is_prime"],
223
- )
224
- db.add(product)
225
- db.flush()
226
- else:
227
- product.title = data["title"]
228
- product.brand = data["brand"]
229
- product.upc = data.get("upc") or product.upc
230
- product.category = data.get("category") or product.category
231
- product.image_url = data.get("image_url") or product.image_url
232
- product.is_prime = data.get("is_prime", product.is_prime)
233
-
234
- product.seller_count = data.get("seller_count", 1)
235
- product.buy_box_winner = data.get("buy_box_winner")
236
- product.buy_box_price = data.get("buy_box_price")
237
- product.buy_box_is_fba = data.get("buy_box_is_fba") if "buy_box_is_fba" in data else product.buy_box_is_fba
238
- product.has_buy_box = data.get("has_buy_box", True)
239
- product.is_amazon_sold = data.get("is_amazon_sold", False)
240
- if data.get("package_weight_lbs") is not None:
241
- product.package_weight_lbs = data.get("package_weight_lbs")
242
- product.last_data_source = data.get("data_source", "live")
243
- product.last_synced_at = datetime.now(timezone.utc)
244
-
245
- history = PriceHistory(
246
- id=str(uuid.uuid4()),
247
- product_id=str(product.id),
248
- price=data["price"],
249
- bsr=data["bsr"],
250
- rating=data["rating"],
251
- review_count=data["review_count"],
252
- in_stock=data["in_stock"],
253
- )
254
- db.add(history)
255
- record_buy_box_snapshot(db, str(product.id), data)
256
- db.commit()
257
- db.refresh(product)
258
-
259
- if data.get("price"):
260
- check_alerts(db, asin, float(data["price"]), data.get("bsr"))
261
-
262
- return product
263
-
264
-
265
- def build_response(db: Session, product: Product, data_source: str = "cached") -> dict:
266
- history = (
267
- db.query(PriceHistory)
268
- .filter(PriceHistory.product_id == product.id)
269
- .order_by(PriceHistory.recorded_at.desc())
270
- .limit(180)
271
- .all()
272
- )
273
- latest = history[0] if history else None
274
- sales_data = estimate_monthly_sales(latest.bsr if latest else 0, product.category or "")
275
-
276
- seller_count = product.seller_count or 1
277
- opportunity_score = None
278
- if latest and sales_data["monthly_units"]:
279
- opportunity_score = calculate_opportunity_score(
280
- bsr=latest.bsr or 0,
281
- review_count=latest.review_count or 0,
282
- monthly_sales=sales_data["monthly_units"],
283
- seller_count=seller_count,
284
- )
285
-
286
- price = float(latest.price) if latest and latest.price else None
287
- rating = float(latest.rating) if latest and latest.rating else None
288
- reviews = latest.review_count if latest else 0
289
-
290
- bb_data = _buy_box_data_from_product(product)
291
- snapshots = get_buy_box_snapshots(db, str(product.id), days=90)
292
- rotation = rotation_from_snapshots(snapshots, range_days=30, fallback_data={**bb_data, "price": price})
293
- history_timeline = build_buy_box_timeline(snapshots, range_days=30)
294
- price_history_rows = [
295
- {
296
- "price": float(h.price) if h.price else None,
297
- "bsr": h.bsr,
298
- "rating": float(h.rating) if h.rating else None,
299
- "review_count": h.review_count,
300
- "recorded_at": h.recorded_at.isoformat(),
301
- }
302
- for h in reversed(history)
303
- ]
304
- historical_charts = build_historical_charts_payload(
305
- snapshots,
306
- price_history_rows,
307
- category=product.category or "default",
308
- range_days=365,
309
- weight_lbs=float(product.package_weight_lbs) if product.package_weight_lbs else 1.0,
310
- )
311
-
312
- buy_box = build_buy_box_analysis(
313
- bb_data,
314
- price=price or 25,
315
- rating=rating or 4.0,
316
- reviews=reviews or 100,
317
- rotation=rotation,
318
- history_timeline=history_timeline,
319
- )
320
-
321
- source = product.last_data_source or data_source
322
- last_synced = product.last_synced_at.isoformat() if product.last_synced_at else None
323
-
324
- return {
325
- "asin": product.asin,
326
- "title": product.title,
327
- "brand": product.brand,
328
- "upc": product.upc,
329
- "category": product.category,
330
- "image_url": product.image_url,
331
- "amazon_url": product.amazon_url,
332
- "is_prime": product.is_prime,
333
- "current_price": price,
334
- "current_bsr": latest.bsr if latest else None,
335
- "current_rating": rating,
336
- "current_review_count": latest.review_count if latest else None,
337
- "in_stock": latest.in_stock if latest else None,
338
- "sales_estimate_monthly": sales_data["monthly_units"],
339
- "revenue_estimate_monthly": round(sales_data["monthly_units"] * price, 2) if sales_data["monthly_units"] and price else None,
340
- "opportunity_score": opportunity_score,
341
- "price_history": price_history_rows,
342
- "buy_box": buy_box,
343
- "buy_box_history": {
344
- "snapshot_count": len(snapshots),
345
- "source": rotation.get("source", "estimated"),
346
- "timeline": history_timeline,
347
- "snapshots": historical_charts.get("buy_box_price_series", []),
348
- "charts": historical_charts,
349
- },
350
- "historical_charts": historical_charts,
351
- "package_weight_lbs": float(product.package_weight_lbs) if product.package_weight_lbs else None,
352
- "data_source": source,
353
- "last_synced_at": last_synced,
354
- }
355
-
356
-
357
- def fetch_and_sync_product(db: Session, asin: str, force: bool = False) -> dict:
358
- product = db.query(Product).filter(Product.asin == asin).first()
359
- needs_refresh = force
360
-
361
- if not needs_refresh and product and product.last_synced_at:
362
- last_synced = product.last_synced_at
363
- if last_synced.tzinfo is None:
364
- last_synced = last_synced.replace(tzinfo=timezone.utc)
365
- age_hours = (datetime.now(timezone.utc) - last_synced).total_seconds() / 3600
366
- needs_refresh = age_hours > 6
367
-
368
- if not needs_refresh and product:
369
- return build_response(db, product, "cached")
370
-
371
- data = scrape_amazon_product(asin)
372
- if not data:
373
- if product:
374
- resp = build_response(db, product, "cached")
375
- resp["data_source"] = product.last_data_source or "cached"
376
- return resp
377
- return None
378
-
379
- try:
380
- product = persist_scrape(db, product, asin, data)
381
- return build_response(db, product, data.get("data_source", "live"))
382
- except Exception as e:
383
- db.rollback()
384
- print(f"[product_service] DB error: {e}")
385
- sales_data = estimate_monthly_sales(data.get("bsr") or 0, data.get("category") or "")
386
- opp = calculate_opportunity_score(
387
- bsr=data.get("bsr") or 0,
388
- review_count=data.get("review_count") or 0,
389
- monthly_sales=sales_data["monthly_units"],
390
- seller_count=data.get("seller_count", 1),
391
- ) if sales_data["monthly_units"] else None
392
- buy_box = build_buy_box_analysis(
393
- data,
394
- price=data.get("price") or 25,
395
- rating=data.get("rating") or 4.0,
396
- reviews=data.get("review_count") or 100,
397
- )
398
- return {
399
- "asin": asin,
400
- "title": data["title"],
401
- "brand": data["brand"],
402
- "category": data["category"],
403
- "image_url": data["image_url"],
404
- "amazon_url": data["amazon_url"],
405
- "is_prime": data.get("is_prime"),
406
- "current_price": data["price"],
407
- "current_bsr": data["bsr"],
408
- "current_rating": data["rating"],
409
- "current_review_count": data["review_count"],
410
- "in_stock": data["in_stock"],
411
- "sales_estimate_monthly": sales_data["monthly_units"],
412
- "revenue_estimate_monthly": round(sales_data["monthly_units"] * (data["price"] or 0), 2) if sales_data["monthly_units"] and data["price"] else None,
413
- "opportunity_score": opp,
414
- "price_history": [],
415
- "buy_box": buy_box,
416
- "data_source": data.get("data_source", "live"),
417
- "last_synced_at": datetime.now(timezone.utc).isoformat(),
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