Skip to content

Commit 514e0ba

Browse files
committed
feat: collection v2, scan pokemon card mvp
1 parent e449b49 commit 514e0ba

59 files changed

Lines changed: 7544 additions & 1164 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ web/goupixdex.key.pub
1010
integrations
1111

1212
# Local Cursor / agent guidelines — not versioned
13-
CLAUDE.md
13+
CLAUDE.md
14+
.claude

api/desktop_cardmarket_server.py

Lines changed: 162 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@
3939
from core.win32_asyncio import ensure_proactor_event_loop
4040
from 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
)
4647
from 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
4749
from services.desktop_cardmarket_runner_service import run_cardmarket_search_job
4850
from services.os_service import OsService
4951

@@ -54,42 +56,6 @@
5456
CARDMARKET_LOGIN_POLL_INTERVAL_SEC = 2.5
5557
CARDMARKET_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

163134
async 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")
281376
async 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-
474529
async 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

485540
async 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:

api/desktop_vinted_server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,20 @@ async def vinted_unlist_after_ebay_sale(
218218
return {"ok": True, "status": "started"}
219219

220220

221+
@router.post("/{article_id}/remove-vinted-listing", status_code=status.HTTP_202_ACCEPTED)
222+
async def remove_vinted_listing(
223+
article_id: int,
224+
user_id: Annotated[int, Depends(get_user_id_introspected)],
225+
raw_token: Annotated[str, Depends(get_bearer_or_query_token)],
226+
remote: Annotated[str, Depends(get_remote_base_flexible)],
227+
) -> dict[str, object]:
228+
"""Retire l’annonce Vinted depuis la fiche article (Chrome / nodriver)."""
229+
asyncio.create_task(
230+
DesktopVintedRunnerService.run_remove_vinted_listing(article_id, user_id, raw_token, remote)
231+
)
232+
return {"ok": True, "status": "started"}
233+
234+
221235
@router.get("/{article_id}/listing-progress")
222236
@router.get("/{article_id}/vinted-progress", include_in_schema=False)
223237
async def article_listing_progress_stream(

0 commit comments

Comments
 (0)