Skip to content

Commit f6a8164

Browse files
updates!
1 parent 577db25 commit f6a8164

10 files changed

Lines changed: 251 additions & 55 deletions

File tree

src/solesearch_api/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
__version__ = "2.2.0"
22

3+
import logging
34
import os
45

56
import logfire
@@ -39,6 +40,7 @@
3940
# Configure Logfire
4041
logfire.configure()
4142
logfire.instrument_fastapi(app)
43+
logger = logging.getLogger(__name__)
4244

4345
# Enable session handling for StocxkX OAuth flow
4446
app.add_middleware(
@@ -58,12 +60,15 @@
5860
async def startup_event():
5961
# Create the database tables
6062
initialize_db()
63+
logger.info("Database initialized")
6164
# Include all routers
6265
app.include_router(sneakers.router)
6366
app.include_router(auth.router)
6467
app.include_router(scrape.router)
68+
logger.info("Routers imported")
6569
# Load the pagination module
6670
add_pagination(app)
71+
logger.info("Pagination module loaded")
6772

6873

6974
@app.get("/docs", include_in_schema=False)

src/solesearch_api/models/enums.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@ class Platform(str, Enum):
1111

1212
class Audience(str, Enum):
1313
UNISEX = "Unisex"
14-
MEN = "Men"
15-
WOMEN = "Women"
16-
YOUTH = "Youth"
14+
MEN = "Mens"
15+
WOMEN = "Womens"
16+
GRADE_SCHOOL = "Grade School"
17+
PRESCHOOL = "Preschool"
1718
TODDLER = "Toddler"
19+
YOUTH = "Youth"
1820
UNKNOWN = "Unknown"
1921

2022

2123
class SizeStandard(str, Enum):
2224
MENS_US = "mens_US"
2325
WOMENS_US = "womens_US"
26+
KIDS_US = "kids_US"
2427

2528

2629
class SneakerSortKey(str, Enum):

src/solesearch_api/models/sneaker.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from datetime import datetime
22
from decimal import Decimal
33
from functools import reduce
4+
from typing import Any
45

56
from pydantic import computed_field
67
from sqlalchemy import Index, UniqueConstraint
8+
from sqlalchemy.dialects.postgresql import JSONB
79
from sqlmodel import Field, Relationship, SQLModel
810

911
from solesearch_api.models.base import TimestampedModel
@@ -28,9 +30,11 @@ class Sneaker(SneakerBase, TimestampedModel, table=True):
2830
Index("ix_sneaker_sku_brand", "sku", "brand"),
2931
)
3032
id: int | None = Field(default=None, primary_key=True)
33+
last_observed: datetime | None = None
3134
stockx_id: str | None = None
3235
stadium_goods_id: str | None = None
3336
source: Platform | None = None
37+
meta: dict = Field(sa_type=JSONB, default_factory=dict)
3438

3539
# Relationships
3640
links: list["Link"] = Relationship(back_populates="sneaker", cascade_delete=True)
@@ -39,6 +43,9 @@ class Sneaker(SneakerBase, TimestampedModel, table=True):
3943
back_populates="sneaker",
4044
cascade_delete=True,
4145
)
46+
nike_launches: list["NikeLaunch"] = Relationship(
47+
back_populates="sneaker", cascade_delete=True
48+
)
4249

4350
def get_links(self) -> list[str]:
4451
return [link.url for link in self.links]
@@ -109,31 +116,38 @@ class PaginatedSneakersPublic(SQLModel):
109116

110117
class SneakerSize(TimestampedModel, table=True):
111118
__table_args__ = (
112-
UniqueConstraint("sneaker_id", "value"),
113-
Index("ix_sneaker_id_value", "sneaker_id", "value"),
119+
UniqueConstraint("sneaker_id", "value", "size_standard"),
120+
Index(
121+
"ix_sneaker_id_value_size_standard",
122+
"sneaker_id",
123+
"value",
124+
"size_standard",
125+
),
114126
)
115127
id: int | None = Field(default=None, primary_key=True)
116-
value: int
128+
value: str
129+
size_standard: SizeStandard
130+
meta: dict = Field(sa_type=JSONB, default_factory=dict)
117131

118132
sneaker_id: int | None = Field(default=None, foreign_key="sneaker.id")
119133
sneaker: Sneaker | None = Relationship(back_populates="sizes")
120134

121135
prices: list["Price"] = Relationship(back_populates="sneaker_size")
122136

123-
def get_standardized(self, size_standard: SizeStandard = SizeStandard.MENS_US):
124-
if size_standard == SizeStandard.MENS_US:
125-
return self.value
126-
127137

128-
class Price(TimestampedModel, table=True):
138+
class Price(SQLModel, table=True):
139+
# Ordered by last_observed timestamp
129140
__table_args__ = (
130-
UniqueConstraint("sneaker_size_id", "platform"),
131141
Index("ix_sneaker_size_id_platform", "sneaker_size_id", "platform"),
142+
Index("ix_amount", "amount"),
143+
Index("ix_last_observed", "last_observed"),
144+
Index("ix_first_observed", "first_observed"),
132145
)
133146
id: int | None = Field(default=None, primary_key=True)
134147
platform: Platform | None = None
135148
amount: int # Monetary values stored as US cents
136-
observed_at: datetime | None = None
149+
first_observed: datetime | None = None
150+
last_observed: datetime | None = None
137151

138152
sneaker_size_id: int | None = Field(default=None, foreign_key="sneakersize.id")
139153
sneaker_size: SneakerSize | None = Relationship(back_populates="prices")
@@ -142,10 +156,6 @@ class Price(TimestampedModel, table=True):
142156
def in_dollars(self) -> Decimal:
143157
return Decimal(self.amount) / 100
144158

145-
@property
146-
def for_size(self) -> int:
147-
return self.sneaker_size.value
148-
149159

150160
class Link(TimestampedModel, table=True):
151161
__table_args__ = (
@@ -173,3 +183,26 @@ class Image(TimestampedModel, table=True):
173183

174184
sneaker_id: int | None = Field(default=None, foreign_key="sneaker.id")
175185
sneaker: Sneaker | None = Relationship(back_populates="images")
186+
187+
188+
class NikeLaunch(SQLModel, table=True):
189+
__table_args__ = (
190+
UniqueConstraint("product_id", "launch_id"),
191+
Index("ix_product_id_launch_id", "product_id", "launch_id"),
192+
)
193+
id: int | None = Field(default=None, primary_key=True)
194+
195+
sneaker_id: int | None = Field(default=None, foreign_key="sneaker.id")
196+
sneaker: Sneaker | None = Relationship(back_populates="nike_launches")
197+
198+
launch_id: str
199+
product_id: str
200+
launch_method: str | None = None
201+
payment_method: str | None = None
202+
launch_link: str | None = None
203+
start_entry_date: datetime | None = None
204+
stop_entry_date: datetime | None = None
205+
stop_notify_date: datetime | None = None
206+
merch_product_status: str | None = None
207+
is_launch_product: bool | None = None
208+
last_observed: datetime | None = None

src/solesearch_api/routes/scrape.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
from celery.result import AsyncResult
2-
from fastapi import APIRouter, Request
2+
from fastapi import APIRouter, HTTPException, Request
33

4-
from solesearch_api.tasks.scraping.retail.adidas.new_releases import (
5-
adidas_new_releases_task,
6-
)
7-
from solesearch_api.tasks.scraping.retail.adidas.pdp import adidas_pdp_task
8-
from solesearch_api.tasks.scraping.retail.nike import nike_scraping_task
4+
from solesearch_api.tasks.scraping import adidas_new_releases_task
5+
from solesearch_api.tasks.scraping import nike_scraping_task
6+
from solesearch_api.tasks.scraping import nike_instock_scraping_task
97

108
router = APIRouter(
119
prefix="/scrape",
@@ -28,21 +26,18 @@ def task_result_factory(request: Request, task: AsyncResult):
2826
}
2927

3028

31-
@router.get("/nike")
32-
async def scrape_nike_new_releases(request: Request):
33-
task = nike_scraping_task.apply_async()
34-
return task_status_factory(request, task)
35-
36-
37-
@router.get("/adidas")
38-
async def scrape_adidas_new_releases(request: Request):
39-
task = adidas_new_releases_task.apply_async()
40-
return task_status_factory(request, task)
41-
42-
43-
@router.get("/adidas/{slug}")
44-
async def scrape_adidas_pdp(request: Request, slug: str):
45-
task = adidas_pdp_task.apply_async(args=[slug])
29+
@router.get("/{retailer}")
30+
async def scrape_retailer_new_releases(
31+
request: Request, retailer: str, nocache: bool = False
32+
):
33+
task_mapping = {
34+
"nike": nike_scraping_task,
35+
"nike_instock": nike_instock_scraping_task,
36+
"adidas": adidas_new_releases_task,
37+
}
38+
if retailer not in task_mapping:
39+
raise HTTPException(status_code=404, detail="Retailer not found")
40+
task = task_mapping[retailer].delay(nocache=nocache)
4641
return task_status_factory(request, task)
4742

4843

src/solesearch_api/tasks/scraping/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@
33
adidas_new_releases_task,
44
)
55
from solesearch_api.tasks.scraping.retail.adidas.pdp import adidas_pdp_task
6-
from solesearch_api.tasks.scraping.retail.nike import nike_scraping_task
6+
from solesearch_api.tasks.scraping.retail.nike.new_releases import nike_scraping_task
7+
from solesearch_api.tasks.scraping.retail.nike.in_stock import (
8+
nike_instock_scraping_task,
9+
)

src/solesearch_api/tasks/scraping/base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime
66
from typing import Callable
77

8-
import logfire
8+
import logging
99
import requests
1010
from requests.adapters import HTTPAdapter
1111
from sqlmodel import Session
@@ -16,6 +16,8 @@
1616
from solesearch_api.models.enums import Audience
1717
from solesearch_api.utils.browser import get_default_browser_headers
1818

19+
logger = logging.getLogger(__name__)
20+
1921

2022
class MisconfigurationError(ValueError):
2123
pass
@@ -52,7 +54,7 @@ def run(self, *args, **kwargs):
5254

5355
def guess_audience(self, gender: str | list[str]) -> Audience:
5456
if gender not in self.audience_mapping:
55-
logfire.warning(
57+
logger.warning(
5658
f"'{gender}' not in {self.__class__.__name__} audience_mapping",
5759
)
5860
return self.audience_mapping.get(gender, Audience.UNKNOWN)

src/solesearch_api/tasks/scraping/retail/adidas/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def __init__(self, *args, **kwargs):
99
audience_mapping={
1010
"M": Audience.MEN,
1111
"W": Audience.WOMEN,
12-
"K": Audience.YOUTH,
12+
"K": Audience.GRADE_SCHOOL,
1313
"U": Audience.UNISEX,
1414
},
1515
headers={

src/solesearch_api/tasks/scraping/retail/adidas/new_releases.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
import logging
12
import re
23
from datetime import datetime, timezone
34

4-
import logfire
5-
65
from solesearch_api.models.enums import Platform
76
from solesearch_api.models.sneaker import Image, Link, Sneaker
87
from solesearch_api.tasks.db.base import create_or_update_sneaker
98
from solesearch_api.tasks.scraping.retail.adidas.base import AdidasScrapingTask
109
from solesearch_api.tasks.scraping.task_registry import register_scraping_task
1110
from solesearch_api.utils.extractors import next_json_extractor
1211

12+
logger = logging.getLogger(__name__)
13+
1314

1415
class AdidasNewReleasesScrapingTask(AdidasScrapingTask):
1516
name = "solesearch_api.tasks.scraping.retail.adidas.new_releases"
@@ -35,7 +36,7 @@ def scrape(self, session, *args, **kwargs):
3536
def json_to_sneaker(self, data: dict) -> Sneaker | None:
3637
sku = data.get("id", "").strip()
3738
if not sku:
38-
logfire.warning(f"No SKU found for {self.brand} product: {data}")
39+
logger.warning(f"No SKU found for {self.brand} product: {data}")
3940
return None
4041

4142
release_date = data.get("preview_to", "").strip()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from solesearch_api.models.enums import Audience
2+
from solesearch_api.tasks.scraping.base import BaseScrapingTask
3+
from solesearch_api.tasks.scraping.task_registry import register_scraping_task
4+
from solesearch_api.utils.extractors import next_json_extractor
5+
6+
7+
class NikeInStockScrapingTask(BaseScrapingTask):
8+
name = "solesearch_api.tasks.scraping.NikeInStockScrapingTask"
9+
10+
def __init__(self):
11+
super().__init__(
12+
brand="NikeInStock",
13+
download_url="https://www.nike.com/launch?s=in-stock",
14+
audience_mapping={
15+
"M": Audience.MEN,
16+
"W": Audience.WOMEN,
17+
},
18+
extractor=next_json_extractor,
19+
)
20+
21+
def scrape(self, session, *args, **kwargs):
22+
json_data = self.get_json()
23+
return json_data
24+
25+
26+
nike_instock_scraping_task = register_scraping_task(NikeInStockScrapingTask)

0 commit comments

Comments
 (0)