walla-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
walla/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """walla - Wallapop.es look · offer · talk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ("__version__",)
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,23 @@
1
+ """Account package facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from walla.account.actions import list_favorites, make_offer, quote_offer
6
+ from walla.account.inbox import list_conversations, list_messages, send_message
7
+ from walla.account.login import login_cookie_text, login_cookies, login_password, whoami
8
+ from walla.account.session_store import clear_session, load_session
9
+
10
+ __all__ = (
11
+ "clear_session",
12
+ "list_conversations",
13
+ "list_favorites",
14
+ "list_messages",
15
+ "load_session",
16
+ "login_cookie_text",
17
+ "login_cookies",
18
+ "login_password",
19
+ "make_offer",
20
+ "quote_offer",
21
+ "send_message",
22
+ "whoami",
23
+ )
@@ -0,0 +1,135 @@
1
+ """Favorites and offers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from walla.account.login import ensure_access_token
8
+ from walla.core.exceptions import WallaUnsupportedError
9
+ from walla.http.client import HttpClient
10
+ from walla.hunter.profile_store import load_profile
11
+ from walla.hunter.verdict import haversine_km
12
+ from walla.models.account import OfferQuote
13
+ from walla.models.listing import Listing
14
+ from walla.search.api import get_item
15
+
16
+ __all__ = (
17
+ "add_favorite",
18
+ "list_favorites",
19
+ "make_offer",
20
+ "quote_offer",
21
+ "remove_favorite",
22
+ )
23
+
24
+ PROTECTION_PCT = 0.09
25
+ SHIPPING_EUR = 4.0
26
+
27
+
28
+ def _auth_client() -> HttpClient:
29
+ sess = ensure_access_token()
30
+ return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
31
+
32
+
33
+ def list_favorites(*, client: HttpClient | None = None) -> list[dict[str, Any]]:
34
+ http = client or _auth_client()
35
+ for path in ("/api/v3/users/me/favorites", "/api/v3/favorites"):
36
+ try:
37
+ raw = http.get(path, auth=True)
38
+ if isinstance(raw, dict):
39
+ items = raw.get("data") or raw.get("items") or raw.get("favorites") or []
40
+ if isinstance(items, list):
41
+ return [i for i in items if isinstance(i, dict)]
42
+ if isinstance(raw, list):
43
+ return [i for i in raw if isinstance(i, dict)]
44
+ except Exception: # noqa: BLE001
45
+ continue
46
+ raise WallaUnsupportedError("Favorites list wire not confirmed. Update docs/WIRE.md.")
47
+
48
+
49
+ def add_favorite(item_id: str, *, client: HttpClient | None = None) -> dict[str, Any]:
50
+ http = client or _auth_client()
51
+ try:
52
+ raw = http.post(f"/api/v3/items/{item_id}/favorite", auth=True)
53
+ return {"favorited": True, "item_id": item_id, "response": raw}
54
+ except Exception as exc:
55
+ raise WallaUnsupportedError(f"Favorite add failed: {exc}") from exc
56
+
57
+
58
+ def remove_favorite(item_id: str, *, client: HttpClient | None = None) -> dict[str, Any]:
59
+ http = client or _auth_client()
60
+ try:
61
+ raw = http.delete(f"/api/v3/items/{item_id}/favorite", auth=True)
62
+ return {"favorited": False, "item_id": item_id, "response": raw}
63
+ except Exception as exc:
64
+ raise WallaUnsupportedError(f"Favorite remove failed: {exc}") from exc
65
+
66
+
67
+ def quote_offer(item: Listing, offer_eur: float) -> OfferQuote:
68
+ protection = round(offer_eur * PROTECTION_PCT, 2)
69
+ shipping = SHIPPING_EUR if item.shippable else 0.0
70
+ profile = load_profile()
71
+ within = True
72
+ if (
73
+ not item.user_allows_shipping
74
+ and profile.lat is not None
75
+ and profile.lon is not None
76
+ and item.location
77
+ and item.location.latitude is not None
78
+ and item.location.longitude is not None
79
+ ):
80
+ dist = haversine_km(
81
+ profile.lat,
82
+ profile.lon,
83
+ item.location.latitude,
84
+ item.location.longitude,
85
+ )
86
+ within = dist <= profile.pickup_km
87
+ return OfferQuote(
88
+ item_id=item.id,
89
+ offer_eur=offer_eur,
90
+ protection_eur=protection,
91
+ shipping_eur=shipping,
92
+ total_eur=round(offer_eur + protection + shipping, 2),
93
+ within_pickup=within,
94
+ )
95
+
96
+
97
+ def make_offer(
98
+ item_id: str,
99
+ offer_eur: float,
100
+ *,
101
+ confirm: bool = False,
102
+ client: HttpClient | None = None,
103
+ ) -> dict[str, Any]:
104
+ if not confirm:
105
+ raise ValueError("offer requires confirm=True / --yes")
106
+ item = get_item(item_id, client=client)
107
+ quote = quote_offer(item, offer_eur)
108
+ if not quote.within_pickup:
109
+ raise ValueError(
110
+ f"In-person listing outside pickup radius ({item.title}). Refuse offer."
111
+ )
112
+ http = client or _auth_client()
113
+ try:
114
+ raw = http.post(
115
+ "/api/v3/delivery/buyer/offers",
116
+ json_body={
117
+ "item_id": item_id,
118
+ "amount": offer_eur,
119
+ "currency": "EUR",
120
+ },
121
+ auth=True,
122
+ )
123
+ return {
124
+ "sent": True,
125
+ "quote": quote.model_dump(mode="json"),
126
+ "title": item.title,
127
+ "response": raw,
128
+ }
129
+ except Exception as exc:
130
+ msg = str(exc)
131
+ if "409" in msg:
132
+ raise WallaUnsupportedError("Seller disabled offers (409).") from exc
133
+ raise WallaUnsupportedError(
134
+ f"Offer wire failed ({exc}). Capture POST buyer/offers and update WIRE.md."
135
+ ) from exc
@@ -0,0 +1,88 @@
1
+ """Parse browser cookie exports for NextAuth session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from walla.core.exceptions import WallaAuthError
9
+
10
+ __all__ = (
11
+ "SESSION_COOKIE",
12
+ "parse_cookie_export",
13
+ "parse_cookie_text",
14
+ )
15
+
16
+ SESSION_COOKIE = "__Secure-next-auth.session-token"
17
+
18
+
19
+ def parse_cookie_export(path: Path) -> tuple[str, str | None]:
20
+ """Return (session_cookie, device_id) from a cookies file."""
21
+ return parse_cookie_text(path.read_text(encoding="utf-8"))
22
+
23
+
24
+ def parse_cookie_text(text: str) -> tuple[str, str | None]:
25
+ """Return (session_cookie, device_id) from pasted text or export body."""
26
+ stripped = text.strip()
27
+ if not stripped:
28
+ raise WallaAuthError(f"empty cookie paste; need {SESSION_COOKIE}")
29
+ if stripped.startswith("[") or stripped.startswith("{"):
30
+ return _from_json(stripped)
31
+ return _from_text(stripped)
32
+
33
+
34
+ def _from_json(text: str) -> tuple[str, str | None]:
35
+ data = json.loads(text)
36
+ session: str | None = None
37
+ device: str | None = None
38
+ rows = data if isinstance(data, list) else [data]
39
+ for row in rows:
40
+ if not isinstance(row, dict):
41
+ continue
42
+ name = row.get("name") or row.get("Name")
43
+ value = row.get("value") or row.get("Value")
44
+ if name == SESSION_COOKIE and value:
45
+ session = str(value)
46
+ if name == "device_id" and value:
47
+ device = str(value)
48
+ if not session:
49
+ raise WallaAuthError(f"no {SESSION_COOKIE} in JSON cookie export")
50
+ return session, device
51
+
52
+
53
+ def _from_text(text: str) -> tuple[str, str | None]:
54
+ session: str | None = None
55
+ device: str | None = None
56
+ for raw in text.splitlines():
57
+ line = raw.strip()
58
+ if not line or (line.startswith("#") and "HttpOnly" not in line):
59
+ continue
60
+ if line.startswith("#HttpOnly_"):
61
+ line = line[len("#HttpOnly_") :]
62
+ if "\t" in line:
63
+ fields = line.split("\t")
64
+ if len(fields) >= 7:
65
+ name, value = fields[5], fields[6]
66
+ if name == SESSION_COOKIE:
67
+ session = value
68
+ elif name == "device_id":
69
+ device = value
70
+ continue
71
+ line = line.removeprefix("Cookie:").strip()
72
+ for pair in line.split(";"):
73
+ pair = pair.strip()
74
+ if "=" not in pair:
75
+ if pair.count(".") >= 2 and len(pair) > 100:
76
+ session = pair
77
+ continue
78
+ name, _, value = pair.partition("=")
79
+ name, value = name.strip(), value.strip()
80
+ if name == SESSION_COOKIE:
81
+ session = value
82
+ elif name == "device_id":
83
+ device = value
84
+ if not session and text.count(".") >= 2 and len(text.strip()) > 100:
85
+ session = text.strip()
86
+ if not session:
87
+ raise WallaAuthError(f"no {SESSION_COOKIE} in cookie paste")
88
+ return session, device
walla/account/inbox.py ADDED
@@ -0,0 +1,147 @@
1
+ """Authenticated inbox / thread / send."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ from walla.account.login import ensure_access_token
8
+ from walla.core.exceptions import WallaUnsupportedError
9
+ from walla.http.client import HttpClient
10
+ from walla.models.account import Conversation, Message
11
+
12
+ __all__ = (
13
+ "list_conversations",
14
+ "list_messages",
15
+ "send_message",
16
+ )
17
+
18
+
19
+ def _auth_client() -> HttpClient:
20
+ sess = ensure_access_token()
21
+ return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
22
+
23
+
24
+ def list_conversations(*, client: HttpClient | None = None) -> list[Conversation]:
25
+ http = client or _auth_client()
26
+ raw = http.get("/api/v3/conversations", params={"max_messages": 1}, auth=True)
27
+ rows = _extract_list(raw)
28
+ out: list[Conversation] = []
29
+ for row in rows:
30
+ if not isinstance(row, dict):
31
+ continue
32
+ cid = str(row.get("id") or row.get("conversation_id") or "")
33
+ if not cid:
34
+ continue
35
+ item = row.get("item") or row.get("product") or {}
36
+ user = row.get("user") or row.get("other_user") or {}
37
+ last = row.get("last_message") or row.get("messages") or {}
38
+ last_text = None
39
+ if isinstance(last, dict):
40
+ last_text = last.get("text") or last.get("message")
41
+ elif isinstance(last, list) and last:
42
+ last_text = last[0].get("text") if isinstance(last[0], dict) else None
43
+ out.append(
44
+ Conversation(
45
+ id=cid,
46
+ item_id=str(item.get("id")) if isinstance(item, dict) and item.get("id") else None,
47
+ item_title=item.get("title") if isinstance(item, dict) else None,
48
+ other_user=(
49
+ user.get("micro_name") or user.get("name")
50
+ if isinstance(user, dict)
51
+ else None
52
+ ),
53
+ unread=int(row.get("unread_count") or row.get("unread") or 0),
54
+ last_message=str(last_text) if last_text else None,
55
+ )
56
+ )
57
+ return out
58
+
59
+
60
+ def list_messages(conversation_id: str, *, client: HttpClient | None = None) -> list[Message]:
61
+ http = client or _auth_client()
62
+ # Try common shapes; first success wins.
63
+ for path in (
64
+ f"/api/v3/conversations/{conversation_id}/messages",
65
+ f"/api/v3/conversations/{conversation_id}",
66
+ ):
67
+ try:
68
+ raw = http.get(path, auth=True)
69
+ except Exception: # noqa: BLE001
70
+ continue
71
+ rows = _extract_messages(raw)
72
+ if rows is not None:
73
+ return rows
74
+ raise WallaUnsupportedError(
75
+ "Message thread wire not confirmed for this account. "
76
+ "Capture GET conversation messages and update docs/WIRE.md."
77
+ )
78
+
79
+
80
+ def send_message(
81
+ conversation_id: str,
82
+ text: str,
83
+ *,
84
+ confirm: bool = False,
85
+ client: HttpClient | None = None,
86
+ ) -> dict[str, Any]:
87
+ if not confirm:
88
+ raise ValueError("send requires confirm=True / --yes")
89
+ http = client or _auth_client()
90
+ try:
91
+ raw = http.post(
92
+ f"/api/v3/conversations/{conversation_id}/messages",
93
+ json_body={"text": text, "message": text},
94
+ auth=True,
95
+ )
96
+ return {"sent": True, "conversation_id": conversation_id, "response": raw}
97
+ except Exception as exc:
98
+ raise WallaUnsupportedError(
99
+ f"Send wire failed ({exc}). Capture POST messages and update docs/WIRE.md."
100
+ ) from exc
101
+
102
+
103
+ def _extract_list(raw: Any) -> list[Any]:
104
+ if isinstance(raw, list):
105
+ return raw
106
+ if not isinstance(raw, dict):
107
+ return []
108
+ for key in ("conversations", "data", "items"):
109
+ val = raw.get(key)
110
+ if isinstance(val, list):
111
+ return val
112
+ if isinstance(val, dict):
113
+ for k2 in ("conversations", "items", "list"):
114
+ if isinstance(val.get(k2), list):
115
+ return cast(list[Any], val[k2])
116
+ return []
117
+
118
+
119
+ def _extract_messages(raw: Any) -> list[Message] | None:
120
+ rows: list[Any] | None = None
121
+ if isinstance(raw, list):
122
+ rows = raw
123
+ elif isinstance(raw, dict):
124
+ for key in ("messages", "data", "items"):
125
+ val = raw.get(key)
126
+ if isinstance(val, list):
127
+ rows = val
128
+ break
129
+ if isinstance(val, dict) and isinstance(val.get("messages"), list):
130
+ rows = val["messages"]
131
+ break
132
+ if rows is None:
133
+ return None
134
+ out: list[Message] = []
135
+ for row in rows:
136
+ if not isinstance(row, dict):
137
+ continue
138
+ text = row.get("text") or row.get("message") or ""
139
+ out.append(
140
+ Message(
141
+ id=str(row["id"]) if row.get("id") else None,
142
+ text=str(text),
143
+ from_self=bool(row.get("from_self") or row.get("mine")),
144
+ created_at=row.get("created_at") or row.get("timestamp"),
145
+ )
146
+ )
147
+ return out
walla/account/login.py ADDED
@@ -0,0 +1,205 @@
1
+ """Login: cookie paste (primary) + optional password."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ from curl_cffi import requests
10
+
11
+ from walla.account.cookies import SESSION_COOKIE, parse_cookie_export, parse_cookie_text
12
+ from walla.account.session_store import load_session, save_session
13
+ from walla.core.exceptions import WallaAuthError, WallaHTTPError
14
+ from walla.http.headers import WEB_BASE, default_headers, new_device_id
15
+ from walla.http.polite import wait_turn
16
+ from walla.models.account import SessionData
17
+
18
+ __all__ = (
19
+ "ensure_access_token",
20
+ "login_cookie_text",
21
+ "login_cookies",
22
+ "login_password",
23
+ "mint_access_token",
24
+ "refresh_session",
25
+ "whoami",
26
+ )
27
+
28
+
29
+ def login_cookie_text(text: str) -> SessionData:
30
+ """Primary path: pasted cookie value, Cookie header, or export body."""
31
+ path_candidate = Path(text.strip()).expanduser()
32
+ if path_candidate.is_file():
33
+ return login_cookies(path_candidate)
34
+ cookie, device = parse_cookie_text(text)
35
+ return _session_from_cookie(cookie, device)
36
+
37
+
38
+ def login_cookies(path: Path) -> SessionData:
39
+ cookie, device = parse_cookie_export(path)
40
+ return _session_from_cookie(cookie, device)
41
+
42
+
43
+ def _session_from_cookie(cookie: str, device: str | None) -> SessionData:
44
+ session = SessionData(session_cookie=cookie, device_id=device or new_device_id())
45
+ token, exp = mint_access_token(session)
46
+ session.access_token = token
47
+ session.expires_at = exp
48
+ save_session(session)
49
+ return session
50
+
51
+
52
+ def login_password(*, username: str, password: str) -> SessionData:
53
+ """Optional fallback. Often fails on MFA; prefer cookie paste."""
54
+ if not username or not password:
55
+ raise WallaAuthError("email and password required")
56
+ device = new_device_id()
57
+ wait_turn()
58
+ headers = default_headers(device_id=device)
59
+ headers["Content-Type"] = "application/json"
60
+ resp = requests.post(
61
+ "https://api.wallapop.com/api/v3/access/login",
62
+ json={"username": username, "password": password},
63
+ headers=headers,
64
+ impersonate="chrome",
65
+ timeout=30,
66
+ )
67
+ if resp.status_code != 200 or not resp.content:
68
+ raise WallaAuthError(
69
+ f"Password login failed (HTTP {resp.status_code}). "
70
+ "Paste the session cookie instead: walla login"
71
+ )
72
+ body = cast(Any, resp.json()) # type: ignore[no-untyped-call]
73
+ data = body.get("data") if isinstance(body, dict) else body
74
+ token_block = data.get("token", data) if isinstance(data, dict) else {}
75
+ access = (
76
+ token_block.get("access_token")
77
+ or token_block.get("accessToken")
78
+ or (data or {}).get("access_token")
79
+ )
80
+ refresh = token_block.get("refresh_token") or token_block.get("refreshToken")
81
+ if not access:
82
+ raise WallaAuthError("Password login returned no access token. Use walla login.")
83
+ session = SessionData(
84
+ access_token=str(access),
85
+ refresh_token=str(refresh) if refresh else None,
86
+ device_id=device,
87
+ expires_at=time.time() + 300,
88
+ )
89
+ save_session(session)
90
+ return session
91
+
92
+
93
+ def mint_access_token(session: SessionData) -> tuple[str, float]:
94
+ """Mint a short-lived Bearer from the NextAuth session cookie."""
95
+ if not session.session_cookie:
96
+ if session.access_token:
97
+ return session.access_token, session.expires_at or (time.time() + 240)
98
+ raise WallaAuthError("No session cookie or access token")
99
+ wait_turn()
100
+ headers = default_headers(device_id=session.device_id)
101
+ headers["Cookie"] = f"{SESSION_COOKIE}={session.session_cookie}"
102
+ resp = requests.get(
103
+ f"{WEB_BASE}/api/auth/session",
104
+ headers=headers,
105
+ impersonate="chrome",
106
+ timeout=30,
107
+ )
108
+ if resp.status_code != 200:
109
+ raise WallaAuthError(f"Session mint failed (HTTP {resp.status_code})")
110
+ data = cast(Any, resp.json()) if resp.content else {} # type: ignore[no-untyped-call]
111
+ token = data.get("token") if isinstance(data, dict) else None
112
+ if not token:
113
+ raise WallaAuthError("Stored session is no longer valid. Run walla login again.")
114
+ for ck in resp.cookies.jar if hasattr(resp.cookies, "jar") else []:
115
+ if getattr(ck, "name", None) == SESSION_COOKIE and ck.value:
116
+ session.session_cookie = ck.value
117
+ exp = time.time() + 240
118
+ return str(token), exp
119
+
120
+
121
+ def refresh_session(session: SessionData | None = None) -> SessionData:
122
+ sess = session or load_session()
123
+ if sess is None:
124
+ raise WallaAuthError("No session. Run walla login.")
125
+ if sess.session_cookie:
126
+ token, exp = mint_access_token(sess)
127
+ sess.access_token = token
128
+ sess.expires_at = exp
129
+ save_session(sess)
130
+ return sess
131
+ if sess.refresh_token:
132
+ wait_turn()
133
+ headers = default_headers(device_id=sess.device_id)
134
+ headers["Content-Type"] = "application/json"
135
+ resp = requests.post(
136
+ "https://api.wallapop.com/api/v3/access/refresh",
137
+ json={"refresh_token": sess.refresh_token},
138
+ headers=headers,
139
+ impersonate="chrome",
140
+ timeout=30,
141
+ )
142
+ if resp.status_code != 200:
143
+ raise WallaAuthError(f"Refresh failed (HTTP {resp.status_code})")
144
+ body = cast(Any, resp.json()) # type: ignore[no-untyped-call]
145
+ data = body.get("data") if isinstance(body, dict) else body
146
+ token_block = data.get("token", data) if isinstance(data, dict) else {}
147
+ access = token_block.get("access_token") or token_block.get("accessToken")
148
+ if not access:
149
+ raise WallaAuthError("Refresh returned no access token")
150
+ sess.access_token = str(access)
151
+ new_refresh = token_block.get("refresh_token") or token_block.get("refreshToken")
152
+ if new_refresh:
153
+ sess.refresh_token = str(new_refresh)
154
+ sess.expires_at = time.time() + 300
155
+ save_session(sess)
156
+ return sess
157
+ raise WallaAuthError("Session has neither cookie nor refresh token")
158
+
159
+
160
+ def ensure_access_token() -> SessionData:
161
+ sess = load_session()
162
+ if sess is None:
163
+ raise WallaAuthError("No session. Run: walla login")
164
+ if sess.expires_at and sess.expires_at > time.time() + 30 and sess.access_token:
165
+ return sess
166
+ return refresh_session(sess)
167
+
168
+
169
+ def whoami(*, client_data: dict[str, Any] | None = None) -> dict[str, Any]:
170
+ """Return account label without secrets."""
171
+ sess = ensure_access_token()
172
+ if client_data:
173
+ return client_data
174
+ wait_turn()
175
+ headers = default_headers(device_id=sess.device_id)
176
+ headers["Authorization"] = f"Bearer {sess.access_token}"
177
+ resp = requests.get(
178
+ "https://api.wallapop.com/api/v3/users/me",
179
+ headers=headers,
180
+ impersonate="chrome",
181
+ timeout=30,
182
+ )
183
+ if resp.status_code == 404:
184
+ return {
185
+ "user_id": sess.user_id,
186
+ "micro_name": sess.micro_name,
187
+ "authenticated": True,
188
+ }
189
+ if resp.status_code != 200:
190
+ raise WallaHTTPError(
191
+ f"whoami failed (HTTP {resp.status_code})",
192
+ status_code=resp.status_code,
193
+ )
194
+ data = cast(Any, resp.json()) # type: ignore[no-untyped-call]
195
+ user = data.get("data") if isinstance(data.get("data"), dict) else data
196
+ micro = user.get("micro_name") or user.get("microName")
197
+ uid = user.get("id") or user.get("user_id")
198
+ sess.micro_name = str(micro) if micro else sess.micro_name
199
+ sess.user_id = str(uid) if uid else sess.user_id
200
+ save_session(sess)
201
+ return {
202
+ "user_id": sess.user_id,
203
+ "micro_name": sess.micro_name,
204
+ "authenticated": True,
205
+ }
@@ -0,0 +1,56 @@
1
+ """Interactive login helpers (cookie paste primary)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from walla.account.cookies import SESSION_COOKIE
11
+ from walla.account.login import login_cookie_text, login_password
12
+ from walla.core.exceptions import WallaAuthError
13
+ from walla.models.account import SessionData
14
+
15
+ __all__ = ("interactive_login",)
16
+
17
+
18
+ def interactive_login(*, password: bool = False) -> SessionData:
19
+ """Prompt for cookie paste, or email/password when ``password`` is True."""
20
+ if password:
21
+ return _prompt_password()
22
+ return _prompt_cookie()
23
+
24
+
25
+ def _prompt_cookie() -> SessionData:
26
+ typer.echo(
27
+ f"Paste {SESSION_COOKIE} (or a cookies.txt path), then Enter:\n"
28
+ " Tip: log in at es.wallapop.com → Cookie-Editor → copy that cookie."
29
+ )
30
+ raw = typer.prompt("cookie").strip()
31
+ if not raw:
32
+ raise WallaAuthError("empty paste")
33
+ return login_cookie_text(raw)
34
+
35
+
36
+ def _prompt_password() -> SessionData:
37
+ typer.echo(
38
+ "Password login often fails (MFA). Prefer: walla login (paste cookie)."
39
+ )
40
+ email = typer.prompt("email").strip()
41
+ pw = getpass.getpass("password: ")
42
+ try:
43
+ return login_password(username=email, password=pw)
44
+ except WallaAuthError:
45
+ typer.echo("Password rejected. Paste the session cookie instead.")
46
+ return _prompt_cookie()
47
+
48
+
49
+ def session_summary(sess: SessionData) -> dict[str, Any]:
50
+ return {
51
+ "authenticated": True,
52
+ "has_access_token": bool(sess.access_token),
53
+ "has_cookie": bool(sess.session_cookie),
54
+ "user_id": sess.user_id,
55
+ "micro_name": sess.micro_name,
56
+ }