3939from core .win32_asyncio import ensure_proactor_event_loop
4040from services .cardmarket_session_service import (
4141 clear_session_info ,
42- parse_account_info_from_html ,
42+ persist_session_from_probe ,
43+ probe_tab_and_persist_session ,
44+ read_session_from_tab ,
4345 read_session_info ,
44- write_session_info ,
4546)
4647from services .cardmarket_scraper_service import HOME_POKEMON_URL , _prepare_clean_main_tab
48+ from services .desktop_cardmarket_orders_sync_service import run_cardmarket_orders_sync_job
4749from services .desktop_cardmarket_runner_service import run_cardmarket_search_job
4850from services .os_service import OsService
4951
5456CARDMARKET_LOGIN_POLL_INTERVAL_SEC = 2.5
5557CARDMARKET_LOGIN_POLL_MAX_SEC = 600.0
5658
57- # Live DOM (same pattern as ``vinted_service._parse_eval_dict_result``): ``get_content()`` can lag
58- # behind what the user sees; ``evaluate`` reads the hydrated page.
59- _CM_LOGIN_PROBE_JS = """
60- (() => {
61- const out = { logged_in: false, username: null, credit_eur: null };
62- const dd = document.querySelector('#account-dropdown');
63- if (!dd) return JSON.stringify(out);
64- const line = dd.querySelector('.line-height115');
65- if (line) {
66- for (const s of line.querySelectorAll('span')) {
67- const t = (s.textContent || '').trim();
68- if (t && !t.includes('€') && !t.startsWith('(') && t.toLowerCase() !== 'particulier' && t.length < 60) {
69- out.username = t;
70- out.logged_in = true;
71- break;
72- }
73- }
74- }
75- if (!out.username) {
76- const mheader = document.querySelector('a#account-dropdown + ul.dropdown-menu h6.dropdown-header');
77- if (mheader) {
78- for (const s of mheader.querySelectorAll('span')) {
79- const t = (s.textContent || '').trim();
80- if (t && !t.includes('€') && !t.startsWith('(') && t.length < 60) { out.username = t; out.logged_in = true; break; }
81- }
82- }
83- }
84- const credit = document.querySelector('#totalCreditMainNav');
85- if (credit) {
86- const m = (credit.textContent || '').match(/(-?\\ d+(?:[.,]\\ d+)?)\\ s*€/);
87- if (m) out.credit_eur = parseFloat(m[1].replace(',', '.'));
88- }
89- return JSON.stringify(out);
90- })()
91- """
92-
9359_INTROSPECT_CACHE_TTL_SEC = 120.0
9460_introspect_cache : dict [str , tuple [float , int ]] = {}
9561
@@ -159,6 +125,11 @@ async def get_user_id_introspected(
159125_ws_lock = asyncio .Lock ()
160126_active_tasks : dict [int , asyncio .Task ] = {}
161127
128+ _orders_sync_ws_clients : dict [int , set [WebSocket ]] = {}
129+ _orders_sync_ws_lock = asyncio .Lock ()
130+ _orders_sync_tasks : dict [int , asyncio .Task ] = {}
131+ _orders_sync_last_event : dict [int , dict [str , Any ]] = {}
132+
162133
163134async def _broadcast_search (search_id : int , payload : dict [str , Any ]) -> None :
164135 async with _ws_lock :
@@ -277,6 +248,130 @@ async def cancel_run(
277248 return {"status" : "cancelled" }
278249
279250
251+ async def _broadcast_orders_sync (user_id : int , payload : dict [str , Any ]) -> None :
252+ """Broadcast a sync event to every active WS client for ``user_id``."""
253+ async with _orders_sync_ws_lock :
254+ clients = list (_orders_sync_ws_clients .get (user_id , set ()))
255+ dead : list [WebSocket ] = []
256+ for ws in clients :
257+ try :
258+ await ws .send_json (payload )
259+ except Exception :
260+ dead .append (ws )
261+ if not dead :
262+ return
263+ async with _orders_sync_ws_lock :
264+ for ws in dead :
265+ _orders_sync_ws_clients .get (user_id , set ()).discard (ws )
266+
267+
268+ @app .get ("/cardmarket/orders/sync/active" )
269+ async def cardmarket_orders_sync_active (
270+ user_id : Annotated [int , Depends (get_user_id_introspected )],
271+ ) -> dict [str , Any ]:
272+ """Whether a sync is currently running for the caller (for the UI to resume)."""
273+ t = _orders_sync_tasks .get (user_id )
274+ active = t is not None and not t .done ()
275+ last = _orders_sync_last_event .get (user_id )
276+ return {"active" : active , "last_event" : last }
277+
278+
279+ @app .post ("/cardmarket/orders/sync" , status_code = status .HTTP_202_ACCEPTED )
280+ async def cardmarket_orders_sync_start (
281+ user_id : Annotated [int , Depends (get_user_id_introspected )],
282+ raw_token : Annotated [str , Depends (get_bearer_or_query_token )],
283+ remote : Annotated [str , Depends (get_remote_base_flexible )],
284+ ) -> dict [str , str ]:
285+ """
286+ Start the Cardmarket purchases-sync job (scrape ``Orders/Purchases/Sent``, dedup, import).
287+
288+ Returns immediately with ``status=started``; subscribe to the WebSocket to follow progress.
289+ """
290+ existing = _orders_sync_tasks .get (user_id )
291+ if existing is not None and not existing .done ():
292+ raise HTTPException (
293+ status_code = status .HTTP_409_CONFLICT ,
294+ detail = "Une synchronisation Cardmarket est déjà en cours pour ce compte." ,
295+ )
296+
297+ await _ensure_cm_login_browser_closed ()
298+
299+ async def emit (ev : dict [str , Any ]) -> None :
300+ _orders_sync_last_event [user_id ] = ev
301+ await _broadcast_orders_sync (user_id , ev )
302+
303+ async def _job () -> None :
304+ await run_cardmarket_orders_sync_job (user_id , raw_token , remote , emit )
305+
306+ task = asyncio .create_task (_job ())
307+ _orders_sync_tasks [user_id ] = task
308+
309+ def _cleanup (_ : asyncio .Task ) -> None :
310+ cur = _orders_sync_tasks .get (user_id )
311+ if cur is task :
312+ _orders_sync_tasks .pop (user_id , None )
313+
314+ task .add_done_callback (_cleanup )
315+ return {"status" : "started" }
316+
317+
318+ @app .post ("/cardmarket/orders/sync/cancel" )
319+ async def cardmarket_orders_sync_cancel (
320+ user_id : Annotated [int , Depends (get_user_id_introspected )],
321+ ) -> dict [str , str ]:
322+ """Cancel the running sync (closes the browser; partially-imported orders are kept)."""
323+ task = _orders_sync_tasks .get (user_id )
324+ if task is None or task .done ():
325+ raise HTTPException (status_code = status .HTTP_404_NOT_FOUND , detail = "Aucune synchronisation active." )
326+ task .cancel ()
327+ try :
328+ await asyncio .wait_for (task , timeout = 20.0 )
329+ except (asyncio .CancelledError , asyncio .TimeoutError ):
330+ pass
331+ except Exception as exc : # noqa: BLE001
332+ logger .debug ("orders sync cancel wait: %s" , exc )
333+ await _broadcast_orders_sync (
334+ user_id ,
335+ {"type" : "cancelled" , "message" : "Synchronisation arrêtée par l’utilisateur." },
336+ )
337+ return {"status" : "cancelled" }
338+
339+
340+ @app .websocket ("/ws/cardmarket/orders/sync/progress" )
341+ async def cardmarket_orders_sync_progress_ws (websocket : WebSocket ) -> None :
342+ """Live progress stream for the orders-sync job (one socket per user)."""
343+ raw_token = websocket .query_params .get ("token" )
344+ remote_raw = websocket .query_params .get ("remote_api" ) or os .environ .get ("GOUPIX_REMOTE_API" , "" )
345+ remote = str (remote_raw ).strip ().rstrip ("/" ) if remote_raw else ""
346+ if not raw_token or not remote :
347+ await websocket .close (code = 1008 )
348+ return
349+ try :
350+ user_id = await introspect_user_id (raw_token .strip (), remote )
351+ except HTTPException :
352+ await websocket .close (code = 1008 )
353+ return
354+
355+ await websocket .accept ()
356+ last = _orders_sync_last_event .get (user_id )
357+ if last is not None :
358+ try :
359+ await websocket .send_json (last )
360+ except Exception :
361+ pass
362+
363+ async with _orders_sync_ws_lock :
364+ _orders_sync_ws_clients .setdefault (user_id , set ()).add (websocket )
365+ try :
366+ while True :
367+ await websocket .receive_text ()
368+ except WebSocketDisconnect :
369+ pass
370+ finally :
371+ async with _orders_sync_ws_lock :
372+ _orders_sync_ws_clients .get (user_id , set ()).discard (websocket )
373+
374+
280375@app .websocket ("/ws/cardmarket-searches/{search_id}/progress" )
281376async def cardmarket_progress_websocket (websocket : WebSocket , search_id : int ) -> None :
282377 raw_token = websocket .query_params .get ("token" )
@@ -336,6 +431,15 @@ async def _safe_close_cm_browser() -> None:
336431 if browser is None :
337432 return
338433
434+ if tab is not None :
435+ try :
436+ profile = _cardmarket_profile_dir ()
437+ info = await read_session_from_tab (tab )
438+ if info .get ("logged_in" ) or info .get ("username" ):
439+ persist_session_from_probe (profile , info , clear_when_absent = False )
440+ except Exception as exc : # noqa: BLE001
441+ logger .debug ("cm session probe before close: %s" , exc )
442+
339443 if tab is not None :
340444 try :
341445 await asyncio .wait_for (tab .get ("about:blank" ), timeout = 5.0 )
@@ -422,55 +526,6 @@ def _pick_cardmarket_tab(browser: Any, preferred: Any | None) -> Any:
422526 return tabs [0 ] if tabs else preferred
423527
424528
425- def _merge_probe_html (js_info : dict [str , Any ], html_info : dict [str , Any ]) -> dict [str , Any ]:
426- """Combine JS (live) and HTML (static) probes: prefer JS username when present."""
427- u_js = js_info .get ("username" ) if isinstance (js_info .get ("username" ), str ) else None
428- u_html = html_info .get ("username" ) if isinstance (html_info .get ("username" ), str ) else None
429- username = (u_js or "" ).strip () or (u_html or "" ).strip () or None
430- credit = js_info .get ("credit_eur" )
431- if credit is None :
432- credit = html_info .get ("credit_eur" )
433- logged_in = bool (username ) or bool (html_info .get ("logged_in" )) or bool (js_info .get ("logged_in" ))
434- return {"logged_in" : logged_in , "username" : username , "credit_eur" : credit }
435-
436-
437- async def _read_session_from_tab (tab : Any ) -> dict [str , Any ]:
438- """Read login state via live JS (hydrated DOM) and static HTML fallback."""
439- from nodriver .cdp import runtime as cdp_runtime
440-
441- js_info : dict [str , Any ] = {"logged_in" : False , "username" : None , "credit_eur" : None }
442- try :
443- raw = await asyncio .wait_for (
444- tab .evaluate (_CM_LOGIN_PROBE_JS , return_by_value = True ),
445- timeout = 12.0 ,
446- )
447- if isinstance (raw , cdp_runtime .ExceptionDetails ):
448- logger .debug ("cm login probe JS: %s" , raw )
449- elif isinstance (raw , str ):
450- parsed = json .loads (raw )
451- if isinstance (parsed , dict ):
452- js_info = parsed
453- elif isinstance (raw , dict ):
454- js_info = raw
455- except (asyncio .TimeoutError , json .JSONDecodeError , TypeError , Exception ) as exc : # noqa: BLE001
456- logger .debug ("cm login probe JS failed: %s" , exc )
457-
458- if js_info .get ("credit_eur" ) is not None :
459- try :
460- js_info ["credit_eur" ] = float (js_info ["credit_eur" ])
461- except (TypeError , ValueError ):
462- js_info ["credit_eur" ] = None
463-
464- html_info : dict [str , Any ] = {"logged_in" : False , "username" : None , "credit_eur" : None }
465- try :
466- html = await asyncio .wait_for (tab .get_content (), timeout = 10.0 )
467- html_info = parse_account_info_from_html (html )
468- except (asyncio .TimeoutError , Exception ) as exc : # noqa: BLE001
469- logger .debug ("cm get_content: %s" , exc )
470-
471- return _merge_probe_html (js_info , html_info )
472-
473-
474529async def _read_session_from_browser (browser : Any , preferred_tab : Any | None ) -> dict [str , Any ]:
475530 try :
476531 await browser .wait (0.12 )
@@ -479,7 +534,7 @@ async def _read_session_from_browser(browser: Any, preferred_tab: Any | None) ->
479534 tab = _pick_cardmarket_tab (browser , preferred_tab )
480535 if tab is None :
481536 return {"logged_in" : False , "username" : None , "credit_eur" : None }
482- return await _read_session_from_tab (tab )
537+ return await read_session_from_tab (tab )
483538
484539
485540async def _open_login_browser_inner () -> dict [str , Any ]:
@@ -594,11 +649,12 @@ async def _login_polling_loop() -> None:
594649 if not (isinstance (username , str ) and username .strip ()):
595650 continue
596651 username = username .strip ()
597- write_session_info (
652+ persist_session_from_probe (
598653 profile ,
599654 {
600655 "username" : username ,
601656 "credit_eur" : info .get ("credit_eur" ),
657+ "logged_in" : True ,
602658 },
603659 )
604660 logger .info ("Cardmarket session detected for %s — closing helper browser to flush cookies" , username )
@@ -617,37 +673,33 @@ async def cardmarket_session(
617673 - If the helper browser is open: live-reads the DOM (and refreshes the JSON cache).
618674 - Otherwise: returns the persisted JSON written by the login polling loop.
619675 """
676+ global _cm_tab
620677 profile = _cardmarket_profile_dir ()
621678 live : dict [str , Any ] | None = None
622679 if _cm_browser is not None :
623680 try :
681+ tab = _pick_cardmarket_tab (_cm_browser , _cm_tab )
682+ if tab is not None :
683+ await tab .get (HOME_POKEMON_URL )
684+ await tab
685+ try :
686+ await tab .wait (0.7 )
687+ except Exception : # noqa: BLE001
688+ await asyncio .sleep (0.7 )
689+ _cm_tab = tab
624690 live = await _read_session_from_browser (_cm_browser , _cm_tab )
691+ if live .get ("logged_in" ) or live .get ("username" ):
692+ persist_session_from_probe (profile , live , clear_when_absent = False )
625693 except Exception as exc : # noqa: BLE001
626694 logger .debug ("session live read: %s" , exc )
627695 live = None
628696
629- if live and (live .get ("logged_in" ) or live .get ("username" )):
630- write_session_info (
631- profile ,
632- {
633- "username" : live .get ("username" ),
634- "credit_eur" : live .get ("credit_eur" ),
635- },
636- )
637-
638697 persisted = read_session_info (profile )
639- live_user : str | None = None
640- if isinstance (live , dict ):
641- u = live .get ("username" )
642- if isinstance (u , str ) and u .strip ():
643- live_user = u .strip ()
644- username = live_user or (persisted .get ("username" ) if persisted else None )
645-
646- credit_eur = None
647- if live_user and isinstance (live , dict ):
648- credit_eur = live .get ("credit_eur" )
649- if credit_eur is None and persisted :
650- credit_eur = persisted .get ("credit_eur" )
698+ username : str | None = None
699+ if persisted and isinstance (persisted .get ("username" ), str ):
700+ username = persisted ["username" ].strip () or None
701+
702+ credit_eur = persisted .get ("credit_eur" ) if persisted else None
651703 last_seen = persisted .get ("last_seen" ) if persisted else None
652704
653705 if username :
0 commit comments