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/hunter/verdict.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""GRAB / LOOK / PASS scoring (pure)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from walla.models.listing import Listing
|
|
8
|
+
from walla.models.profile import Profile
|
|
9
|
+
|
|
10
|
+
__all__ = (
|
|
11
|
+
"haversine_km",
|
|
12
|
+
"score_listing",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
17
|
+
r = 6371.0
|
|
18
|
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
19
|
+
dphi = math.radians(lat2 - lat1)
|
|
20
|
+
dlmb = math.radians(lon2 - lon1)
|
|
21
|
+
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
|
|
22
|
+
return 2 * r * math.asin(math.sqrt(a))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def score_listing(listing: Listing, profile: Profile) -> str:
|
|
26
|
+
"""Return GRAB / LOOK / PASS."""
|
|
27
|
+
if listing.reserved:
|
|
28
|
+
return "PASS"
|
|
29
|
+
budget = profile.budget or profile.max_price
|
|
30
|
+
if budget is not None and listing.price.amount > budget:
|
|
31
|
+
return "PASS"
|
|
32
|
+
if profile.lat is not None and profile.lon is not None and listing.location:
|
|
33
|
+
la, lo = listing.location.latitude, listing.location.longitude
|
|
34
|
+
if la is not None and lo is not None:
|
|
35
|
+
dist = haversine_km(profile.lat, profile.lon, la, lo)
|
|
36
|
+
if dist > profile.km and not listing.user_allows_shipping:
|
|
37
|
+
return "PASS"
|
|
38
|
+
if budget is not None and listing.price.amount <= budget * 0.85 and dist <= profile.km:
|
|
39
|
+
return "GRAB"
|
|
40
|
+
if budget is not None and listing.price.amount <= budget * 0.9:
|
|
41
|
+
return "GRAB"
|
|
42
|
+
return "LOOK"
|
walla/hunter/watches.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Local saved searches with new-id diff."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from walla.core.path import watches_path
|
|
12
|
+
|
|
13
|
+
__all__ = (
|
|
14
|
+
"Watch",
|
|
15
|
+
"WatchStore",
|
|
16
|
+
"diff_new_ids",
|
|
17
|
+
"load_watches",
|
|
18
|
+
"save_watches",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Watch(BaseModel):
|
|
23
|
+
id: str
|
|
24
|
+
keywords: str
|
|
25
|
+
max_price: float | None = None
|
|
26
|
+
category_id: int | None = None
|
|
27
|
+
seen_ids: list[str] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WatchStore(BaseModel):
|
|
31
|
+
schema_version: int = 1
|
|
32
|
+
watches: list[Watch] = Field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_watches(path: Path | None = None) -> WatchStore:
|
|
36
|
+
p = path or watches_path()
|
|
37
|
+
if not p.is_file():
|
|
38
|
+
return WatchStore()
|
|
39
|
+
return WatchStore.model_validate(json.loads(p.read_text(encoding="utf-8")))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_watches(store: WatchStore, path: Path | None = None) -> Path:
|
|
43
|
+
p = path or watches_path()
|
|
44
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
p.write_text(store.model_dump_json(indent=2), encoding="utf-8")
|
|
46
|
+
return p
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def diff_new_ids(seen: list[str], found: list[str]) -> list[str]:
|
|
50
|
+
known = set(seen)
|
|
51
|
+
return [i for i in found if i not in known]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def merge_seen(watch: Watch, found_ids: list[str]) -> Watch:
|
|
55
|
+
merged = list(dict.fromkeys([*watch.seen_ids, *found_ids]))
|
|
56
|
+
return watch.model_copy(update={"seen_ids": merged})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def watch_to_dict(watch: Watch) -> dict[str, Any]:
|
|
60
|
+
return watch.model_dump(mode="json")
|
walla/mcp/__init__.py
ADDED
walla/mcp/_entry.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""MCP entrypoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ("run", "run_http")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run() -> None:
|
|
9
|
+
from walla.mcp.server import build_server
|
|
10
|
+
|
|
11
|
+
build_server().run()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_http() -> None:
|
|
15
|
+
from walla.mcp.server import build_server
|
|
16
|
+
|
|
17
|
+
build_server().run(transport="streamable-http", host="127.0.0.1", port=8000)
|
walla/mcp/server.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""FastMCP server over walla core (no CLI imports)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from walla.account.actions import make_offer, quote_offer
|
|
8
|
+
from walla.account.inbox import list_conversations, list_messages, send_message
|
|
9
|
+
from walla.account.login import login_cookie_text, whoami
|
|
10
|
+
from walla.core.envelope import error_payload, success_payload
|
|
11
|
+
from walla.core.instruct import instruct_recipe
|
|
12
|
+
from walla.hunter.profile_store import load_profile
|
|
13
|
+
from walla.hunter.verdict import score_listing
|
|
14
|
+
from walla.search.api import get_categories, get_item, search_listings
|
|
15
|
+
|
|
16
|
+
__all__ = ("build_server",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _wrap(fn: Any) -> dict[str, Any]:
|
|
20
|
+
try:
|
|
21
|
+
return success_payload(fn())
|
|
22
|
+
except Exception as exc: # noqa: BLE001
|
|
23
|
+
return error_payload(exc)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_server() -> Any:
|
|
27
|
+
from fastmcp import FastMCP
|
|
28
|
+
|
|
29
|
+
mcp = FastMCP("walla")
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
def instruct() -> dict[str, Any]:
|
|
33
|
+
return _wrap(instruct_recipe)
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
def search(
|
|
37
|
+
keywords: str,
|
|
38
|
+
max_results: int = 40,
|
|
39
|
+
min_price: float | None = None,
|
|
40
|
+
max_price: float | None = None,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
def _run() -> dict[str, Any]:
|
|
43
|
+
profile = load_profile()
|
|
44
|
+
if not profile.ready or profile.lat is None or profile.lon is None:
|
|
45
|
+
return {
|
|
46
|
+
"ready": False,
|
|
47
|
+
"intake": {"prompt_to_user": profile.intake_prompt()},
|
|
48
|
+
}
|
|
49
|
+
result = search_listings(
|
|
50
|
+
keywords,
|
|
51
|
+
latitude=profile.lat,
|
|
52
|
+
longitude=profile.lon,
|
|
53
|
+
max_results=max_results,
|
|
54
|
+
min_price=min_price,
|
|
55
|
+
max_price=max_price or profile.budget,
|
|
56
|
+
)
|
|
57
|
+
rows = []
|
|
58
|
+
for listing in result.listings:
|
|
59
|
+
listing.verdict = score_listing(listing, profile)
|
|
60
|
+
rows.append(listing.model_dump(mode="json"))
|
|
61
|
+
return {"count": len(rows), "listings": rows}
|
|
62
|
+
|
|
63
|
+
return _wrap(_run)
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
def item(item_id: str) -> dict[str, Any]:
|
|
67
|
+
def _run() -> dict[str, Any]:
|
|
68
|
+
listing = get_item(item_id)
|
|
69
|
+
listing.verdict = score_listing(listing, load_profile())
|
|
70
|
+
return listing.model_dump(mode="json")
|
|
71
|
+
|
|
72
|
+
return _wrap(_run)
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
def categories() -> dict[str, Any]:
|
|
76
|
+
return _wrap(
|
|
77
|
+
lambda: {"categories": [c.model_dump(mode="json") for c in get_categories()]}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def login(cookie: str) -> dict[str, Any]:
|
|
82
|
+
"""Log in with a pasted __Secure-next-auth.session-token value."""
|
|
83
|
+
|
|
84
|
+
def _run() -> dict[str, Any]:
|
|
85
|
+
sess = login_cookie_text(cookie)
|
|
86
|
+
return {
|
|
87
|
+
"authenticated": True,
|
|
88
|
+
"has_access_token": bool(sess.access_token),
|
|
89
|
+
"has_cookie": bool(sess.session_cookie),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return _wrap(_run)
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
def inbox() -> dict[str, Any]:
|
|
96
|
+
return _wrap(
|
|
97
|
+
lambda: {
|
|
98
|
+
"conversations": [c.model_dump(mode="json") for c in list_conversations()]
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@mcp.tool()
|
|
103
|
+
def thread(conversation_id: str) -> dict[str, Any]:
|
|
104
|
+
return _wrap(
|
|
105
|
+
lambda: {
|
|
106
|
+
"messages": [
|
|
107
|
+
m.model_dump(mode="json") for m in list_messages(conversation_id)
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def say(conversation_id: str, text: str, confirm: bool = False) -> dict[str, Any]:
|
|
114
|
+
return _wrap(lambda: send_message(conversation_id, text, confirm=confirm))
|
|
115
|
+
|
|
116
|
+
@mcp.tool()
|
|
117
|
+
def offer(item_id: str, eur: float, confirm: bool = False) -> dict[str, Any]:
|
|
118
|
+
def _run() -> dict[str, Any]:
|
|
119
|
+
if not confirm:
|
|
120
|
+
listing = get_item(item_id)
|
|
121
|
+
q = quote_offer(listing, eur)
|
|
122
|
+
return {
|
|
123
|
+
"needs_confirm": True,
|
|
124
|
+
"quote": q.model_dump(mode="json"),
|
|
125
|
+
"title": listing.title,
|
|
126
|
+
}
|
|
127
|
+
return make_offer(item_id, eur, confirm=True)
|
|
128
|
+
|
|
129
|
+
return _wrap(_run)
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
def me() -> dict[str, Any]:
|
|
133
|
+
return _wrap(whoami)
|
|
134
|
+
|
|
135
|
+
return mcp
|
walla/models/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Models facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from walla.models.account import Conversation, Message, OfferQuote, SessionData
|
|
6
|
+
from walla.models.listing import Listing, SearchResult
|
|
7
|
+
from walla.models.profile import Category, Profile
|
|
8
|
+
|
|
9
|
+
__all__ = (
|
|
10
|
+
"Category",
|
|
11
|
+
"Conversation",
|
|
12
|
+
"Listing",
|
|
13
|
+
"Message",
|
|
14
|
+
"OfferQuote",
|
|
15
|
+
"Profile",
|
|
16
|
+
"SearchResult",
|
|
17
|
+
"SessionData",
|
|
18
|
+
)
|
walla/models/account.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Session and messaging models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"Conversation",
|
|
9
|
+
"Message",
|
|
10
|
+
"OfferQuote",
|
|
11
|
+
"SessionData",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionData(BaseModel):
|
|
16
|
+
schema_version: int = 1
|
|
17
|
+
access_token: str | None = None
|
|
18
|
+
refresh_token: str | None = None
|
|
19
|
+
session_cookie: str | None = None
|
|
20
|
+
device_id: str | None = None
|
|
21
|
+
user_id: str | None = None
|
|
22
|
+
micro_name: str | None = None
|
|
23
|
+
expires_at: float | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Conversation(BaseModel):
|
|
27
|
+
id: str
|
|
28
|
+
item_id: str | None = None
|
|
29
|
+
item_title: str | None = None
|
|
30
|
+
other_user: str | None = None
|
|
31
|
+
unread: int = 0
|
|
32
|
+
last_message: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Message(BaseModel):
|
|
36
|
+
id: str | None = None
|
|
37
|
+
text: str
|
|
38
|
+
from_self: bool = False
|
|
39
|
+
created_at: int | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OfferQuote(BaseModel):
|
|
43
|
+
item_id: str
|
|
44
|
+
offer_eur: float
|
|
45
|
+
protection_eur: float
|
|
46
|
+
shipping_eur: float
|
|
47
|
+
total_eur: float
|
|
48
|
+
within_pickup: bool = True
|
walla/models/listing.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pydantic listing models (no I/O)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"Listing",
|
|
9
|
+
"ListingLocation",
|
|
10
|
+
"Money",
|
|
11
|
+
"SearchResult",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Money(BaseModel):
|
|
16
|
+
amount: float
|
|
17
|
+
currency: str = "EUR"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ListingLocation(BaseModel):
|
|
21
|
+
latitude: float | None = None
|
|
22
|
+
longitude: float | None = None
|
|
23
|
+
city: str | None = None
|
|
24
|
+
postal_code: str | None = None
|
|
25
|
+
region: str | None = None
|
|
26
|
+
country_code: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Listing(BaseModel):
|
|
30
|
+
id: str
|
|
31
|
+
title: str
|
|
32
|
+
description: str | None = None
|
|
33
|
+
price: Money
|
|
34
|
+
web_slug: str
|
|
35
|
+
url: str
|
|
36
|
+
category_id: int | None = None
|
|
37
|
+
reserved: bool = False
|
|
38
|
+
shippable: bool = False
|
|
39
|
+
user_allows_shipping: bool = False
|
|
40
|
+
location: ListingLocation | None = None
|
|
41
|
+
image_url: str | None = None
|
|
42
|
+
created_at: int | None = None
|
|
43
|
+
modified_at: int | None = None
|
|
44
|
+
user_id: str | None = None
|
|
45
|
+
verdict: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SearchResult(BaseModel):
|
|
49
|
+
listings: list[Listing] = Field(default_factory=list)
|
|
50
|
+
next_page: str | None = None
|
|
51
|
+
count: int = 0
|
walla/models/profile.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Category and profile models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"Category",
|
|
9
|
+
"Profile",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Category(BaseModel):
|
|
14
|
+
id: int
|
|
15
|
+
name: str
|
|
16
|
+
icon: str | None = None
|
|
17
|
+
vertical_id: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Profile(BaseModel):
|
|
21
|
+
schema_version: int = 1
|
|
22
|
+
lat: float | None = None
|
|
23
|
+
lon: float | None = None
|
|
24
|
+
km: float = 30.0
|
|
25
|
+
pickup_km: float = 30.0
|
|
26
|
+
label: str | None = None
|
|
27
|
+
max_price: float | None = None
|
|
28
|
+
budget: float | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def ready(self) -> bool:
|
|
32
|
+
return self.lat is not None and self.lon is not None
|
|
33
|
+
|
|
34
|
+
def intake_prompt(self) -> str:
|
|
35
|
+
return (
|
|
36
|
+
"lat=41.39 lon=2.17 km=30 pickup_km=30 label=Barcelona budget=250"
|
|
37
|
+
)
|
walla/search/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Search package facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from walla.search.api import get_categories, get_item, search_listings
|
|
6
|
+
from walla.search.parse import parse_categories, parse_item, parse_search
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"get_categories",
|
|
10
|
+
"get_item",
|
|
11
|
+
"parse_categories",
|
|
12
|
+
"parse_item",
|
|
13
|
+
"parse_search",
|
|
14
|
+
"search_listings",
|
|
15
|
+
)
|
walla/search/api.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Public Wallapop search / item / categories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from walla.http.client import HttpClient
|
|
8
|
+
from walla.models.listing import Listing, SearchResult
|
|
9
|
+
from walla.models.profile import Category
|
|
10
|
+
from walla.search.parse import parse_categories, parse_item, parse_search
|
|
11
|
+
|
|
12
|
+
__all__ = (
|
|
13
|
+
"get_categories",
|
|
14
|
+
"get_item",
|
|
15
|
+
"search_listings",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def search_listings(
|
|
20
|
+
keywords: str,
|
|
21
|
+
*,
|
|
22
|
+
latitude: float,
|
|
23
|
+
longitude: float,
|
|
24
|
+
max_results: int = 40,
|
|
25
|
+
min_price: float | None = None,
|
|
26
|
+
max_price: float | None = None,
|
|
27
|
+
category_id: int | None = None,
|
|
28
|
+
order_by: str = "most_relevance",
|
|
29
|
+
next_page: str | None = None,
|
|
30
|
+
client: HttpClient | None = None,
|
|
31
|
+
) -> SearchResult:
|
|
32
|
+
http = client or HttpClient()
|
|
33
|
+
collected: list[Listing] = []
|
|
34
|
+
cursor = next_page
|
|
35
|
+
last: SearchResult | None = None
|
|
36
|
+
while len(collected) < max_results:
|
|
37
|
+
params: dict[str, Any] = {
|
|
38
|
+
"source": "search_box",
|
|
39
|
+
"keywords": keywords,
|
|
40
|
+
"latitude": latitude,
|
|
41
|
+
"longitude": longitude,
|
|
42
|
+
"order_by": order_by,
|
|
43
|
+
}
|
|
44
|
+
if min_price is not None:
|
|
45
|
+
params["min_sale_price"] = min_price
|
|
46
|
+
if max_price is not None:
|
|
47
|
+
params["max_sale_price"] = max_price
|
|
48
|
+
if category_id is not None:
|
|
49
|
+
params["category_id"] = category_id
|
|
50
|
+
if cursor:
|
|
51
|
+
params["next_page"] = cursor
|
|
52
|
+
raw = http.get("/api/v3/search", params=params, use_cache=True)
|
|
53
|
+
page = parse_search(raw)
|
|
54
|
+
last = page
|
|
55
|
+
if not page.listings:
|
|
56
|
+
break
|
|
57
|
+
collected.extend(page.listings)
|
|
58
|
+
if not page.next_page:
|
|
59
|
+
break
|
|
60
|
+
cursor = page.next_page
|
|
61
|
+
listings = collected[:max_results]
|
|
62
|
+
return SearchResult(
|
|
63
|
+
listings=listings,
|
|
64
|
+
next_page=last.next_page if last else None,
|
|
65
|
+
count=len(listings),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_item(item_id: str, *, client: HttpClient | None = None) -> Listing:
|
|
70
|
+
http = client or HttpClient()
|
|
71
|
+
raw = http.get(f"/api/v3/items/{item_id}", use_cache=True)
|
|
72
|
+
return parse_item(raw)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_categories(*, client: HttpClient | None = None) -> list[Category]:
|
|
76
|
+
http = client or HttpClient()
|
|
77
|
+
raw = http.get("/api/v3/categories", use_cache=True)
|
|
78
|
+
return parse_categories(raw)
|
walla/search/parse.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Parse Wallapop search / item JSON into Listing models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from walla.core.exceptions import WallaParseError
|
|
8
|
+
from walla.models.listing import Listing, ListingLocation, Money, SearchResult
|
|
9
|
+
from walla.models.profile import Category
|
|
10
|
+
|
|
11
|
+
__all__ = (
|
|
12
|
+
"item_url",
|
|
13
|
+
"parse_categories",
|
|
14
|
+
"parse_item",
|
|
15
|
+
"parse_search",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def item_url(web_slug: str) -> str:
|
|
20
|
+
return f"https://es.wallapop.com/item/{web_slug}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _flag(obj: Any) -> bool:
|
|
24
|
+
if isinstance(obj, dict):
|
|
25
|
+
return bool(obj.get("flag"))
|
|
26
|
+
return bool(obj)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _money(obj: Any) -> Money:
|
|
30
|
+
if isinstance(obj, dict):
|
|
31
|
+
amount = obj.get("amount")
|
|
32
|
+
if isinstance(amount, dict):
|
|
33
|
+
amount = amount.get("value") or amount.get("amount")
|
|
34
|
+
currency = obj.get("currency") or "EUR"
|
|
35
|
+
if amount is None:
|
|
36
|
+
raise WallaParseError("missing price.amount")
|
|
37
|
+
return Money(amount=float(amount), currency=str(currency))
|
|
38
|
+
raise WallaParseError("invalid price")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _location(obj: Any) -> ListingLocation | None:
|
|
42
|
+
if not isinstance(obj, dict):
|
|
43
|
+
return None
|
|
44
|
+
return ListingLocation(
|
|
45
|
+
latitude=obj.get("latitude"),
|
|
46
|
+
longitude=obj.get("longitude"),
|
|
47
|
+
city=obj.get("city"),
|
|
48
|
+
postal_code=obj.get("postal_code"),
|
|
49
|
+
region=obj.get("region"),
|
|
50
|
+
country_code=obj.get("country_code"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _image_url(images: Any) -> str | None:
|
|
55
|
+
if not isinstance(images, list) or not images:
|
|
56
|
+
return None
|
|
57
|
+
first = images[0]
|
|
58
|
+
if not isinstance(first, dict):
|
|
59
|
+
return None
|
|
60
|
+
urls = first.get("urls") or {}
|
|
61
|
+
if isinstance(urls, dict):
|
|
62
|
+
return urls.get("medium") or urls.get("small") or urls.get("big")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_listing_row(raw: dict[str, Any]) -> Listing:
|
|
67
|
+
web_slug = raw.get("web_slug") or raw.get("slug") or ""
|
|
68
|
+
title = raw.get("title")
|
|
69
|
+
if isinstance(title, dict):
|
|
70
|
+
title = title.get("original") or title.get("text") or ""
|
|
71
|
+
description = raw.get("description")
|
|
72
|
+
if isinstance(description, dict):
|
|
73
|
+
description = description.get("original") or description.get("text")
|
|
74
|
+
shipping = raw.get("shipping") or {}
|
|
75
|
+
price_raw = raw.get("price")
|
|
76
|
+
if isinstance(price_raw, dict) and "amount" not in price_raw and "cash" in price_raw:
|
|
77
|
+
price_raw = price_raw.get("cash")
|
|
78
|
+
return Listing(
|
|
79
|
+
id=str(raw["id"]),
|
|
80
|
+
title=str(title or ""),
|
|
81
|
+
description=str(description) if description else None,
|
|
82
|
+
price=_money(price_raw),
|
|
83
|
+
web_slug=str(web_slug),
|
|
84
|
+
url=item_url(str(web_slug)) if web_slug else "",
|
|
85
|
+
category_id=int(raw["category_id"]) if raw.get("category_id") is not None else None,
|
|
86
|
+
reserved=_flag(raw.get("reserved")),
|
|
87
|
+
shippable=bool(
|
|
88
|
+
shipping.get("item_is_shippable")
|
|
89
|
+
if isinstance(shipping, dict)
|
|
90
|
+
else raw.get("supports_shipping")
|
|
91
|
+
),
|
|
92
|
+
user_allows_shipping=bool(
|
|
93
|
+
shipping.get("user_allows_shipping") if isinstance(shipping, dict) else False
|
|
94
|
+
),
|
|
95
|
+
location=_location(raw.get("location")),
|
|
96
|
+
image_url=_image_url(raw.get("images")),
|
|
97
|
+
created_at=raw.get("created_at") or raw.get("modified_date"),
|
|
98
|
+
modified_at=raw.get("modified_at") or raw.get("modified_date"),
|
|
99
|
+
user_id=(raw.get("user_id") or (raw.get("user") or {}).get("id")),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_search(payload: dict[str, Any]) -> SearchResult:
|
|
104
|
+
data = payload.get("data") or payload
|
|
105
|
+
section = data.get("section") if isinstance(data, dict) else None
|
|
106
|
+
items_raw: list[Any] = []
|
|
107
|
+
if isinstance(section, dict):
|
|
108
|
+
pl = section.get("payload") or {}
|
|
109
|
+
items_raw = pl.get("items") or []
|
|
110
|
+
elif isinstance(data, dict) and "search_objects" in data:
|
|
111
|
+
items_raw = data["search_objects"]
|
|
112
|
+
if not isinstance(items_raw, list):
|
|
113
|
+
raise WallaParseError("search items missing")
|
|
114
|
+
listings = [parse_listing_row(i) for i in items_raw if isinstance(i, dict)]
|
|
115
|
+
meta = payload.get("meta") or {}
|
|
116
|
+
next_page = meta.get("next_page") if isinstance(meta, dict) else None
|
|
117
|
+
return SearchResult(listings=listings, next_page=next_page, count=len(listings))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_item(payload: dict[str, Any]) -> Listing:
|
|
121
|
+
raw = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
|
122
|
+
if not isinstance(raw, dict) or "id" not in raw:
|
|
123
|
+
raise WallaParseError("item payload missing id")
|
|
124
|
+
# Detail endpoint uses title.original / slug
|
|
125
|
+
if "web_slug" not in raw and "slug" in raw:
|
|
126
|
+
raw = {**raw, "web_slug": raw["slug"]}
|
|
127
|
+
if "category_id" not in raw and isinstance(raw.get("taxonomy"), list) and raw["taxonomy"]:
|
|
128
|
+
tid = raw["taxonomy"][0].get("id")
|
|
129
|
+
if tid is not None:
|
|
130
|
+
raw = {**raw, "category_id": int(tid)}
|
|
131
|
+
return parse_listing_row(raw)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_categories(payload: dict[str, Any]) -> list[Category]:
|
|
135
|
+
cats = payload.get("categories") or payload.get("data") or []
|
|
136
|
+
if not isinstance(cats, list):
|
|
137
|
+
raise WallaParseError("categories missing")
|
|
138
|
+
out: list[Category] = []
|
|
139
|
+
for c in cats:
|
|
140
|
+
if not isinstance(c, dict):
|
|
141
|
+
continue
|
|
142
|
+
out.append(
|
|
143
|
+
Category(
|
|
144
|
+
id=int(c["id"]),
|
|
145
|
+
name=str(c.get("name") or ""),
|
|
146
|
+
icon=c.get("icon"),
|
|
147
|
+
vertical_id=c.get("vertical_id"),
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
return out
|