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.
@@ -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"
@@ -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
@@ -0,0 +1,7 @@
1
+ """MCP package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from walla.mcp._entry import run, run_http
6
+
7
+ __all__ = ("run", "run_http")
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
@@ -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
+ )
@@ -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
@@ -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
@@ -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
+ )
@@ -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