66from typing import Annotated , Any
77
88from fastapi import APIRouter , Depends , HTTPException , Query , status
9+ from pydantic import BaseModel , Field
910
1011from app_types .ebay_browse import (
1112 ConditionFilter ,
2223from services .ebay_sold_scrape_rate_limit import acquire_sold_scrape_slot
2324from services .ebay_sold_scrape_service import ebay_fr_sold_search_url , scrape_sold_listings
2425from services .ebay_sold_top_service import aggregate_top_sold
26+ from services .ebay_sold_top_worker import get_job , peek_items_sample , submit_job
2527
2628logger = logging .getLogger (__name__ )
2729
@@ -179,14 +181,33 @@ async def sold_scrape_html(
179181 Window goes up to ``720`` hours (30 days).
180182 """
181183 app = get_settings ()
184+
185+ # If the worker has a fresh cached top result for the same (q, window),
186+ # reuse its items_sample — saves an eBay roundtrip *and* the rate-limit
187+ # slot, which matters when the user just searched in Top mode and
188+ # switches to List mode.
189+ cached_sample = peek_items_sample (q = q .strip (), window_hours = window_hours )
190+ if cached_sample is not None :
191+ return {
192+ "query" : q .strip (),
193+ "window_hours" : window_hours ,
194+ "items" : cached_sample [:limit ],
195+ "error" : None ,
196+ "ebay_sold_search_url" : ebay_fr_sold_search_url (
197+ q = q .strip (), page_size = min (60 , max (limit , 10 )),
198+ ),
199+ "source" : "ebay_html_scrape_cached_from_top" ,
200+ "cached" : True ,
201+ }
202+
182203 retry_after = await acquire_sold_scrape_slot (user .id , app .ebay_sold_scrape_min_interval_seconds )
183204 if retry_after > 0 :
184205 iv = app .ebay_sold_scrape_min_interval_seconds
185206 raise HTTPException (
186207 status_code = status .HTTP_429_TOO_MANY_REQUESTS ,
187208 detail = (
188- f"Limite : une recherche « vendus eBay » toutes les { iv :g} s "
189- f"(réessayez dans { retry_after } s)."
209+ f"Rate limit: at most one eBay sold-search every { iv :g} s "
210+ f"(retry in { retry_after } s)."
190211 ),
191212 headers = {"Retry-After" : str (retry_after )},
192213 )
@@ -198,64 +219,94 @@ async def sold_scrape_html(
198219 "error" : err ,
199220 "ebay_sold_search_url" : ebay_fr_sold_search_url (q = q .strip (), page_size = min (60 , max (limit , 10 ))),
200221 "source" : "ebay_html_scrape" ,
222+ "cached" : False ,
201223 }
202224
203225
204- @router .get ("/sold-top" , response_model = None )
205- async def sold_top (
226+ class SoldTopSubmitBody (BaseModel ):
227+ """Body for ``POST /ebay/market/sold-top`` — schedules a background scrape."""
228+
229+ q : str = Field (min_length = 2 , max_length = 256 )
230+ window_hours : float = Field (default = 168 , ge = 1 , le = 720 )
231+ pages : int = Field (default = 10 , ge = 1 , le = 20 )
232+ scrape_limit : int = Field (default = 600 , ge = 10 , le = 1000 )
233+ top_limit : int = Field (default = 20 , ge = 1 , le = 100 )
234+ min_count : int = Field (default = 1 , ge = 1 , le = 20 )
235+
236+
237+ @router .post ("/sold-top" , response_model = None , status_code = status .HTTP_202_ACCEPTED )
238+ async def sold_top_submit (
206239 user : Annotated [User , Depends (get_current_user )],
207- q : Annotated [str , Query (min_length = 2 , max_length = 256 )],
208- window_hours : Annotated [float , Query (ge = 1 , le = 720 )] = 168 ,
209- pages : Annotated [int , Query (ge = 1 , le = 5 )] = 2 ,
210- scrape_limit : Annotated [int , Query (ge = 10 , le = 300 )] = 180 ,
211- top_limit : Annotated [int , Query (ge = 1 , le = 100 )] = 30 ,
212- min_count : Annotated [int , Query (ge = 1 , le = 20 )] = 1 ,
240+ body : SoldTopSubmitBody ,
213241) -> dict [str , Any ]:
214242 """
215- Top des cartes les plus vendues dans la fenêtre, agrégées depuis le
216- scrape HTML public eBay.fr .
243+ Submit a background top-sold scrape job and return its ``job_id``
244+ (consumed via ``GET /ebay/market/sold-top/{job_id}``) .
217245
218- Le résultat est trié par ``count`` (puis valeur cumulée). Fenêtre par
219- défaut : 7 jours (168 h) ; valeurs autorisées de 1 h à 720 h (30 j).
220- ``pages`` (1-5) déclenche autant de requêtes paginées vers eBay (60
221- annonces / page), avec déduplication par ``item_id``. Même rate-limit
222- utilisateur que ``/sold-scrape`` — un appel utilisateur peut donc
223- générer plusieurs requêtes vers eBay.
246+ When a fresh cached result (TTL 15 min) exists for the same parameters,
247+ the job comes back already in ``status="completed"`` with its
248+ ``result`` populated — no eBay scrape triggered. The per-user rate-limit
249+ only fires when an actual scrape is launched.
224250 """
225251 app = get_settings ()
226- retry_after = await acquire_sold_scrape_slot (user .id , app .ebay_sold_scrape_min_interval_seconds )
227- if retry_after > 0 :
228- iv = app .ebay_sold_scrape_min_interval_seconds
229- raise HTTPException (
230- status_code = status .HTTP_429_TOO_MANY_REQUESTS ,
231- detail = (
232- f"Limite : une recherche « vendus eBay » toutes les { iv :g} s "
233- f"(réessayez dans { retry_after } s)."
234- ),
235- headers = {"Retry-After" : str (retry_after )},
236- )
237- items , err = await scrape_sold_listings (
238- q = q .strip (),
239- window_hours = window_hours ,
240- limit = scrape_limit ,
241- pages = pages ,
252+ job = submit_job (
253+ user_id = user .id ,
254+ q = body .q .strip (),
255+ window_hours = body .window_hours ,
256+ pages = body .pages ,
257+ scrape_limit = body .scrape_limit ,
258+ top_limit = body .top_limit ,
259+ min_count = body .min_count ,
242260 app = app ,
243261 )
244- grouped = aggregate_top_sold (items , min_count = min_count , limit_per_category = top_limit )
262+
263+ cache_hit = job .status == "completed" and job .result is not None
264+ if not cache_hit :
265+ retry_after = await acquire_sold_scrape_slot (
266+ user .id , app .ebay_sold_scrape_min_interval_seconds ,
267+ )
268+ if retry_after > 0 :
269+ iv = app .ebay_sold_scrape_min_interval_seconds
270+ raise HTTPException (
271+ status_code = status .HTTP_429_TOO_MANY_REQUESTS ,
272+ detail = (
273+ f"Rate limit: at most one eBay sold-search every { iv :g} s "
274+ f"(retry in { retry_after } s)."
275+ ),
276+ headers = {"Retry-After" : str (retry_after )},
277+ )
278+
245279 return {
246- "query" : q .strip (),
247- "window_hours" : window_hours ,
248- "pages_requested" : pages ,
249- "total_observed" : len (items ),
250- "cards" : grouped ["cards" ],
251- "graded" : grouped ["graded" ],
252- "sealed" : grouped ["sealed" ],
253- "groups_count" : {
254- "cards" : len (grouped ["cards" ]),
255- "graded" : len (grouped ["graded" ]),
256- "sealed" : len (grouped ["sealed" ]),
257- },
258- "error" : err ,
259- "ebay_sold_search_url" : ebay_fr_sold_search_url (q = q .strip (), page_size = 60 ),
260- "source" : "ebay_html_scrape_aggregated" ,
280+ ** job .to_public (),
281+ "ebay_sold_search_url" : ebay_fr_sold_search_url (q = body .q .strip (), page_size = 60 ),
282+ "cached" : cache_hit ,
283+ }
284+
285+
286+ @router .get ("/sold-top/{job_id}" , response_model = None )
287+ async def sold_top_status (
288+ user : Annotated [User , Depends (get_current_user )],
289+ job_id : str ,
290+ ) -> dict [str , Any ]:
291+ """
292+ Return the current state of a ``sold-top`` job.
293+
294+ The client polls this endpoint while ``status`` is ``pending`` or
295+ ``running``. Once ``completed`` (or ``failed``), ``result`` is populated
296+ and polling can stop. A job may only be read by its creator.
297+ """
298+ job = get_job (job_id )
299+ if job is None :
300+ raise HTTPException (
301+ status_code = status .HTTP_404_NOT_FOUND ,
302+ detail = "Unknown or expired job." ,
303+ )
304+ if job .user_id != user .id :
305+ raise HTTPException (
306+ status_code = status .HTTP_403_FORBIDDEN ,
307+ detail = "This job does not belong to you." ,
308+ )
309+ return {
310+ ** job .to_public (),
311+ "ebay_sold_search_url" : ebay_fr_sold_search_url (q = job .q , page_size = 60 ),
261312 }
0 commit comments