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 +7 -0
- walla/account/__init__.py +23 -0
- walla/account/actions.py +135 -0
- walla/account/cookies.py +88 -0
- walla/account/inbox.py +147 -0
- walla/account/login.py +205 -0
- walla/account/prompt.py +56 -0
- walla/account/session_store.py +36 -0
- walla/cli/__init__.py +7 -0
- walla/cli/banner.py +33 -0
- walla/cli/catch.py +43 -0
- walla/cli/cmd_account.py +162 -0
- walla/cli/cmd_look.py +143 -0
- walla/cli/cmd_ops.py +100 -0
- walla/cli/console.py +9 -0
- walla/cli/main.py +44 -0
- walla/core/__init__.py +12 -0
- walla/core/envelope.py +46 -0
- walla/core/errors.py +90 -0
- walla/core/exceptions.py +67 -0
- walla/core/human_fix.py +26 -0
- walla/core/instruct.py +50 -0
- walla/core/path.py +32 -0
- walla/http/__init__.py +14 -0
- walla/http/client.py +169 -0
- walla/http/headers.py +36 -0
- walla/http/polite.py +108 -0
- walla/hunter/__init__.py +18 -0
- walla/hunter/profile_store.py +56 -0
- walla/hunter/verdict.py +42 -0
- walla/hunter/watches.py +60 -0
- walla/mcp/__init__.py +7 -0
- walla/mcp/_entry.py +17 -0
- walla/mcp/server.py +135 -0
- walla/models/__init__.py +18 -0
- walla/models/account.py +48 -0
- walla/models/listing.py +51 -0
- walla/models/profile.py +37 -0
- walla/search/__init__.py +15 -0
- walla/search/api.py +78 -0
- walla/search/parse.py +150 -0
- walla_cli-0.1.0.dist-info/METADATA +204 -0
- walla_cli-0.1.0.dist-info/RECORD +46 -0
- walla_cli-0.1.0.dist-info/WHEEL +4 -0
- walla_cli-0.1.0.dist-info/entry_points.txt +4 -0
- walla_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
walla/__init__.py
ADDED
|
@@ -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
|
+
)
|
walla/account/actions.py
ADDED
|
@@ -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
|
walla/account/cookies.py
ADDED
|
@@ -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
|
+
}
|
walla/account/prompt.py
ADDED
|
@@ -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
|
+
}
|