walla-cli 0.1.4__tar.gz → 0.1.6__tar.gz
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_cli-0.1.4 → walla_cli-0.1.6}/PKG-INFO +4 -2
- {walla_cli-0.1.4 → walla_cli-0.1.6}/README.md +3 -1
- {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/WIRE.md +3 -1
- {walla_cli-0.1.4 → walla_cli-0.1.6}/pyproject.toml +1 -1
- {walla_cli-0.1.4 → walla_cli-0.1.6}/skills/walla/SKILL.md +12 -9
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_account.py +5 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_edges.py +2 -1
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_negotiate.py +30 -1
- walla_cli-0.1.6/tests/test_shortlist.py +50 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_verdict.py +23 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/__init__.py +1 -1
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/actions.py +7 -3
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/cmd_look.py +12 -2
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/instruct.py +33 -4
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/negotiate.py +33 -20
- walla_cli-0.1.6/walla/hunter/shortlist.py +50 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/verdict.py +32 -10
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/mcp/server.py +13 -3
- {walla_cli-0.1.4 → walla_cli-0.1.6}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/.gitignore +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/AGENTS.md +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/LICENSE +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/code_quality.md +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/mcp.md +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/objective_function.md +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/fixtures/categories.json +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/scripts/check_code_quality.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/scripts/dogfood_run.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_account_more.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_catch.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_core.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_export.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_live.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_parsers.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_polite.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_session_store.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/cookies.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/inbox.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/login.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/prompt.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/banner.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/cmd_account.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/cmd_ops.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/console.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/main.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/errors.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/path.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/client.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/headers.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/polite.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/export.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/export_pdf.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/profile_store.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/account.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/listing.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/profile.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/search/__init__.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/search/api.py +0 -0
- {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/search/parse.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: walla-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Wallapop.es CLI - look · offer · talk for humans and agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/berendgort/wallapop-cli
|
|
@@ -149,7 +149,7 @@ Optional MCP:
|
|
|
149
149
|
| Step | Agent alone | Ask human |
|
|
150
150
|
|------|-------------|-----------|
|
|
151
151
|
| Search inside mandate | yes | if query vague or budget unset |
|
|
152
|
-
| Shortlist | show
|
|
152
|
+
| Shortlist | show ranked Wallapop rows with markdown item links | if more than one GRAB, pick the id |
|
|
153
153
|
| Negotiate draft | yes | approve Spanish text |
|
|
154
154
|
| `say --yes` | never | yes to that exact text |
|
|
155
155
|
| `offer --yes` | never; refuse if EUR > budget | yes to that exact EUR |
|
|
@@ -161,6 +161,8 @@ Rules:
|
|
|
161
161
|
- `--yes` means send chat or price offer only. It never completes a purchase.
|
|
162
162
|
- If they only asked to look, do not send.
|
|
163
163
|
- Ambiguous match or multiple GRABs: ask once with candidates.
|
|
164
|
+
- Always present Wallapop shortlists ranked best-first with a markdown link on every item URL.
|
|
165
|
+
- Default search is all of Spain. Shipping counts anywhere. Use `walla search --local` only when the human asked for nearby or pickup.
|
|
164
166
|
- In-person offers outside pickup radius: refuse.
|
|
165
167
|
- Envelope: `ok`, `api_version`, `data` or `error` / `error_type` / `retryable`.
|
|
166
168
|
|
|
@@ -105,7 +105,7 @@ Optional MCP:
|
|
|
105
105
|
| Step | Agent alone | Ask human |
|
|
106
106
|
|------|-------------|-----------|
|
|
107
107
|
| Search inside mandate | yes | if query vague or budget unset |
|
|
108
|
-
| Shortlist | show
|
|
108
|
+
| Shortlist | show ranked Wallapop rows with markdown item links | if more than one GRAB, pick the id |
|
|
109
109
|
| Negotiate draft | yes | approve Spanish text |
|
|
110
110
|
| `say --yes` | never | yes to that exact text |
|
|
111
111
|
| `offer --yes` | never; refuse if EUR > budget | yes to that exact EUR |
|
|
@@ -117,6 +117,8 @@ Rules:
|
|
|
117
117
|
- `--yes` means send chat or price offer only. It never completes a purchase.
|
|
118
118
|
- If they only asked to look, do not send.
|
|
119
119
|
- Ambiguous match or multiple GRABs: ask once with candidates.
|
|
120
|
+
- Always present Wallapop shortlists ranked best-first with a markdown link on every item URL.
|
|
121
|
+
- Default search is all of Spain. Shipping counts anywhere. Use `walla search --local` only when the human asked for nearby or pickup.
|
|
120
122
|
- In-person offers outside pickup radius: refuse.
|
|
121
123
|
- Envelope: `ok`, `api_version`, `data` or `error` / `error_type` / `retryable`.
|
|
122
124
|
|
|
@@ -48,7 +48,9 @@ Reported shapes (confirm with a live token before trusting writes):
|
|
|
48
48
|
|
|
49
49
|
- Inbox: `GET /api/v3/conversations`
|
|
50
50
|
- Messages: `GET|POST /api/v3/conversations/{id}/messages`
|
|
51
|
-
- Offers: `POST /api/v3/delivery/buyer/offers`
|
|
51
|
+
- Offers: `POST /api/v3/delivery/buyer/offers` with
|
|
52
|
+
`{offer_id, offer_price_amount, offer_price_currency, item_ids}`.
|
|
53
|
+
`offer_id` is a client UUID. `item_ids` is a one-element list of the listing id.
|
|
52
54
|
- Favorites: `/api/v3/users/me/favorites`, `/api/v3/items/{id}/favorite`
|
|
53
55
|
|
|
54
56
|
Writes that fail closed as `error_type: unsupported` until fixtures exist.
|
|
@@ -16,7 +16,7 @@ license: MIT
|
|
|
16
16
|
pipx install 'walla-cli[mcp]' # PyPI: https://pypi.org/project/walla-cli/
|
|
17
17
|
walla doctor --json
|
|
18
18
|
# If auth.session is false: ask human to run `walla login` and paste cookie
|
|
19
|
-
walla instruct --json # read mandate + hitl.ladder
|
|
19
|
+
walla instruct --json # read mandate + hitl.ladder + present
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
If `mandate.budget` is null, ask once:
|
|
@@ -28,12 +28,15 @@ walla setup --lat 41.39 --lon 2.17 --km 30 --budget 400 \
|
|
|
28
28
|
|
|
29
29
|
## Loop (efficient HITL)
|
|
30
30
|
|
|
31
|
-
1. Search inside
|
|
31
|
+
1. Search Spain-wide inside budget: `walla search "…" --json`
|
|
32
|
+
Distance only if the human asked nearby or pickup: add `--local`.
|
|
32
33
|
Optional shortlist share: `--export md,csv,html,pdf --out ./shortlist`
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
2. **Present Wallapop results** from `data.shortlist` (already ranked best-first):
|
|
35
|
+
say they are on wallapop.es, number them 1…N, and put a markdown link on
|
|
36
|
+
every `https://es.wallapop.com/item/…` URL. Never summarize without links.
|
|
37
|
+
3. One GRAB → `walla negotiate <id> --json`. Several GRABs → ask human to pick an id.
|
|
38
|
+
4. Show opening + offer_eur + walk_away. Human approves text and EUR.
|
|
39
|
+
5. Only then: `walla say … --yes` or `walla offer … --eur N --yes`
|
|
40
|
+
6. **Stop.** Human pays or meets in the Wallapop app. `--yes` never means buy.
|
|
41
|
+
|
|
42
|
+
Reads and negotiate drafts are safe. Sends need confirm.
|
|
@@ -126,3 +126,8 @@ def test_make_offer_confirm_and_wire(
|
|
|
126
126
|
out = make_offer("1", 40, confirm=True)
|
|
127
127
|
assert out["sent"] is True
|
|
128
128
|
assert out["quote"]["offer_eur"] == 40
|
|
129
|
+
body = client.post.call_args.kwargs["json_body"]
|
|
130
|
+
assert body["item_ids"] == ["1"]
|
|
131
|
+
assert body["offer_price_amount"] == 40
|
|
132
|
+
assert body["offer_price_currency"] == "EUR"
|
|
133
|
+
assert body["offer_id"] == out["offer_id"]
|
|
@@ -68,7 +68,8 @@ def test_verdict_distance_pass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
|
|
|
68
68
|
user_allows_shipping=False,
|
|
69
69
|
location=ListingLocation(latitude=40.4, longitude=-3.7),
|
|
70
70
|
)
|
|
71
|
-
assert score_listing(far, p) == "
|
|
71
|
+
assert score_listing(far, p) == "GRAB"
|
|
72
|
+
assert score_listing(far, p, local=True) == "PASS"
|
|
72
73
|
|
|
73
74
|
|
|
74
75
|
def test_cookie_bare_token(tmp_path: Path) -> None:
|
|
@@ -51,10 +51,20 @@ def test_draft_opening_is_specific_and_polite() -> None:
|
|
|
51
51
|
def test_offer_band_never_lowballs() -> None:
|
|
52
52
|
profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=400)
|
|
53
53
|
brief = draft_negotiation(_listing(price=200), profile)
|
|
54
|
+
assert brief.offer_eur == 164 # fair is 82% of ask
|
|
54
55
|
assert brief.offer_eur >= 200 * 0.80
|
|
55
56
|
assert brief.offer_eur <= brief.walk_away_eur
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
def test_fair_on_800_opens_near_650() -> None:
|
|
60
|
+
profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=850)
|
|
61
|
+
brief = draft_negotiation(
|
|
62
|
+
_listing(price=800, ship=True, title="North Reach 13m"),
|
|
63
|
+
profile,
|
|
64
|
+
)
|
|
65
|
+
assert brief.offer_eur == 656
|
|
66
|
+
|
|
67
|
+
|
|
58
68
|
def test_firm_offer_at_floor_within_budget() -> None:
|
|
59
69
|
profile = Profile(
|
|
60
70
|
lat=41.39, lon=2.17, km=30, pickup_km=30, budget=400, aggressiveness="firm"
|
|
@@ -91,9 +101,28 @@ def test_refuse_outside_pickup() -> None:
|
|
|
91
101
|
draft_negotiation(far, profile)
|
|
92
102
|
|
|
93
103
|
|
|
94
|
-
def
|
|
104
|
+
def test_shipping_draft_asks_envio_not_pickup() -> None:
|
|
105
|
+
profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=800)
|
|
106
|
+
brief = draft_negotiation(
|
|
107
|
+
_listing(price=800, lat=39.53, lon=2.72, ship=True, title="North Reach 13m 2023"),
|
|
108
|
+
profile,
|
|
109
|
+
)
|
|
110
|
+
blob = f"{brief.opening}\n{brief.offer_line}".lower()
|
|
111
|
+
assert "envío" in blob
|
|
112
|
+
assert "pasar hoy" not in blob
|
|
113
|
+
assert "recojo" not in blob
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_instruct_spain_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
95
117
|
monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
|
|
96
118
|
data = instruct_recipe()
|
|
97
119
|
assert data["hitl"]["never_pay"] is True
|
|
98
120
|
assert data["hitl"]["yes_means"]
|
|
99
121
|
assert any(s["step"] == "pay_or_buy" for s in data["hitl"]["ladder"])
|
|
122
|
+
blob = " ".join(data["rules"]) + data["hitl"]["ladder"][0]["agent"]
|
|
123
|
+
assert "Spain" in blob
|
|
124
|
+
assert "--local" in blob
|
|
125
|
+
assert "present" in data
|
|
126
|
+
assert "wallapop.es" in data["present"]["channel"]
|
|
127
|
+
assert "es.wallapop.com/item" in data["present"]["format"]
|
|
128
|
+
assert "link" in data["hitl"]["ladder"][1]["agent"].lower()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Ranked shortlist helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from walla.hunter.shortlist import PRESENT_RULE, rank_shortlist
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_rank_grab_before_look_and_cheaper_first() -> None:
|
|
9
|
+
rows = [
|
|
10
|
+
{
|
|
11
|
+
"id": "look-cheap",
|
|
12
|
+
"title": "Look cheap",
|
|
13
|
+
"verdict": "LOOK",
|
|
14
|
+
"price": {"amount": 200},
|
|
15
|
+
"url": "https://es.wallapop.com/item/look-cheap",
|
|
16
|
+
"location": {"city": "A"},
|
|
17
|
+
"shippable": True,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "grab-dear",
|
|
21
|
+
"title": "Grab dear",
|
|
22
|
+
"verdict": "GRAB",
|
|
23
|
+
"price": {"amount": 700},
|
|
24
|
+
"url": "https://es.wallapop.com/item/grab-dear",
|
|
25
|
+
"location": {"city": "B"},
|
|
26
|
+
"user_allows_shipping": True,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "grab-cheap",
|
|
30
|
+
"title": "Grab cheap",
|
|
31
|
+
"verdict": "GRAB",
|
|
32
|
+
"price": {"amount": 430},
|
|
33
|
+
"url": "https://es.wallapop.com/item/grab-cheap",
|
|
34
|
+
"location": {"city": "C"},
|
|
35
|
+
"shippable": True,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "pass",
|
|
39
|
+
"title": "Pass",
|
|
40
|
+
"verdict": "PASS",
|
|
41
|
+
"price": {"amount": 10},
|
|
42
|
+
"url": "https://es.wallapop.com/item/pass",
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
ranked = rank_shortlist(rows)
|
|
46
|
+
assert [r["id"] for r in ranked] == ["grab-cheap", "grab-dear", "look-cheap"]
|
|
47
|
+
assert ranked[0]["rank"] == 1
|
|
48
|
+
assert ranked[0]["url"].startswith("https://es.wallapop.com/item/")
|
|
49
|
+
assert "Wallapop" in PRESENT_RULE
|
|
50
|
+
assert "markdown" in PRESENT_RULE.lower()
|
|
@@ -42,6 +42,29 @@ def test_verdict_grab() -> None:
|
|
|
42
42
|
assert score_listing(_listing(price=Money(amount=100, currency="EUR")), p) == "GRAB"
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def test_far_shipping_is_grab_unless_local() -> None:
|
|
46
|
+
p = Profile(lat=41.39, lon=2.17, km=30, budget=800)
|
|
47
|
+
far = _listing(
|
|
48
|
+
price=Money(amount=700, currency="EUR"),
|
|
49
|
+
user_allows_shipping=True,
|
|
50
|
+
shippable=True,
|
|
51
|
+
location=ListingLocation(latitude=39.53, longitude=2.72, city="Mallorca"),
|
|
52
|
+
)
|
|
53
|
+
assert score_listing(far, p) == "GRAB"
|
|
54
|
+
assert score_listing(far, p, local=True) == "PASS"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_far_pickup_stays_visible() -> None:
|
|
58
|
+
p = Profile(lat=41.39, lon=2.17, km=5, budget=500)
|
|
59
|
+
far = _listing(
|
|
60
|
+
price=Money(amount=400, currency="EUR"),
|
|
61
|
+
user_allows_shipping=False,
|
|
62
|
+
location=ListingLocation(latitude=40.4, longitude=-3.7, city="Madrid"),
|
|
63
|
+
)
|
|
64
|
+
assert score_listing(far, p) == "GRAB"
|
|
65
|
+
assert score_listing(far, p, local=True) == "PASS"
|
|
66
|
+
|
|
67
|
+
|
|
45
68
|
def test_watch_diff_and_merge() -> None:
|
|
46
69
|
assert diff_new_ids(["a"], ["a", "b"]) == ["b"]
|
|
47
70
|
w = Watch(id="1", keywords="bici", seen_ids=["a"])
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import uuid
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from walla.account.login import ensure_access_token
|
|
@@ -116,18 +117,21 @@ def make_offer(
|
|
|
116
117
|
f"In-person listing outside pickup radius ({item.title}). Refuse offer."
|
|
117
118
|
)
|
|
118
119
|
http = client or _auth_client()
|
|
120
|
+
offer_id = str(uuid.uuid4())
|
|
119
121
|
try:
|
|
120
122
|
raw = http.post(
|
|
121
123
|
"/api/v3/delivery/buyer/offers",
|
|
122
124
|
json_body={
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
125
|
+
"offer_id": offer_id,
|
|
126
|
+
"offer_price_amount": offer_eur,
|
|
127
|
+
"offer_price_currency": "EUR",
|
|
128
|
+
"item_ids": [item_id],
|
|
126
129
|
},
|
|
127
130
|
auth=True,
|
|
128
131
|
)
|
|
129
132
|
return {
|
|
130
133
|
"sent": True,
|
|
134
|
+
"offer_id": offer_id,
|
|
131
135
|
"quote": quote.model_dump(mode="json"),
|
|
132
136
|
"title": item.title,
|
|
133
137
|
"response": raw,
|
|
@@ -11,7 +11,8 @@ import typer
|
|
|
11
11
|
from walla.cli.catch import run_cmd
|
|
12
12
|
from walla.hunter.export import load_last_search, save_last_search, write_exports
|
|
13
13
|
from walla.hunter.profile_store import load_profile
|
|
14
|
-
from walla.hunter.
|
|
14
|
+
from walla.hunter.shortlist import PRESENT_RULE, rank_shortlist
|
|
15
|
+
from walla.hunter.verdict import geo_scope, score_listing
|
|
15
16
|
from walla.hunter.watches import (
|
|
16
17
|
Watch,
|
|
17
18
|
diff_new_ids,
|
|
@@ -33,6 +34,9 @@ def register(app: typer.Typer) -> None:
|
|
|
33
34
|
max_price: float | None = typer.Option(None, "--max-price"),
|
|
34
35
|
category_id: int | None = typer.Option(None, "--category"),
|
|
35
36
|
order_by: str = typer.Option("most_relevance", "--order"),
|
|
37
|
+
local: bool = typer.Option(
|
|
38
|
+
False, "--local", help="PASS listings outside profile.km"
|
|
39
|
+
),
|
|
36
40
|
export: str | None = typer.Option(
|
|
37
41
|
None, "--export", help="Formats: md,csv,html,pdf"
|
|
38
42
|
),
|
|
@@ -58,7 +62,7 @@ def register(app: typer.Typer) -> None:
|
|
|
58
62
|
)
|
|
59
63
|
rows = []
|
|
60
64
|
for listing in result.listings:
|
|
61
|
-
listing.verdict = score_listing(listing, profile)
|
|
65
|
+
listing.verdict = score_listing(listing, profile, local=local)
|
|
62
66
|
rows.append(listing.model_dump(mode="json"))
|
|
63
67
|
mandate = {
|
|
64
68
|
"budget": profile.spend_cap,
|
|
@@ -66,15 +70,21 @@ def register(app: typer.Typer) -> None:
|
|
|
66
70
|
"must_match": profile.must_match,
|
|
67
71
|
}
|
|
68
72
|
save_last_search(keywords=keywords, listings=rows, mandate=mandate)
|
|
73
|
+
shortlist = rank_shortlist(rows)
|
|
69
74
|
payload: dict[str, Any] = {
|
|
70
75
|
"count": len(rows),
|
|
71
76
|
"next_page": result.next_page,
|
|
72
77
|
"listings": rows,
|
|
78
|
+
"shortlist": shortlist,
|
|
79
|
+
"geo": geo_scope(local=local),
|
|
73
80
|
"mandate": mandate,
|
|
81
|
+
"source": "wallapop.es",
|
|
74
82
|
"hitl": {
|
|
75
83
|
"grabs": sum(1 for r in rows if r.get("verdict") == "GRAB"),
|
|
76
84
|
"ask_if_multiple_grabs": True,
|
|
85
|
+
"present": PRESENT_RULE,
|
|
77
86
|
"next": (
|
|
87
|
+
"Show shortlist with markdown links (data.shortlist). "
|
|
78
88
|
"If one GRAB: walla negotiate <id> --json. "
|
|
79
89
|
"If several: ask human to pick an id. Never pay."
|
|
80
90
|
),
|
|
@@ -36,12 +36,20 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
36
36
|
"ladder": [
|
|
37
37
|
{
|
|
38
38
|
"step": "search",
|
|
39
|
-
"agent":
|
|
39
|
+
"agent": (
|
|
40
|
+
"alone, Spain-wide, inside budget + must_match. "
|
|
41
|
+
"Keep model and size in the keywords. "
|
|
42
|
+
"--local only if the human asked nearby or pickup."
|
|
43
|
+
),
|
|
40
44
|
"ask_human": "only if query vague or budget unset",
|
|
41
45
|
},
|
|
42
46
|
{
|
|
43
47
|
"step": "shortlist",
|
|
44
|
-
"agent":
|
|
48
|
+
"agent": (
|
|
49
|
+
"Say these are Wallapop.es results. Show numbered "
|
|
50
|
+
"data.shortlist best-first with markdown links on every "
|
|
51
|
+
"https://es.wallapop.com/item/… URL. Never omit links."
|
|
52
|
+
),
|
|
45
53
|
"ask_human": "if more than one GRAB, human picks the id",
|
|
46
54
|
},
|
|
47
55
|
{
|
|
@@ -75,11 +83,28 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
75
83
|
"If no session: ask the human to run `walla login` and paste "
|
|
76
84
|
"__Secure-next-auth.session-token (or pass --cookie for non-interactive).",
|
|
77
85
|
"If mandate.budget is null: ask once for budget + aggression + must words.",
|
|
78
|
-
"Do the ask with walla … --json
|
|
86
|
+
"Do the ask with walla … --json. Always narrate as Wallapop.es "
|
|
87
|
+
"and paste data.shortlist with full item URLs (markdown links).",
|
|
79
88
|
"Draft with walla negotiate; never say/offer without human --yes; never pay.",
|
|
80
89
|
],
|
|
90
|
+
"present": {
|
|
91
|
+
"channel": "wallapop.es",
|
|
92
|
+
"format": (
|
|
93
|
+
"1. GRAB · Title · €N · City\n"
|
|
94
|
+
" [open on Wallapop](https://es.wallapop.com/item/<web_slug>)"
|
|
95
|
+
),
|
|
96
|
+
"order": "best-first: GRAB then LOOK; lower EUR within band",
|
|
97
|
+
"rule": (
|
|
98
|
+
"Every shortlist row must include the full item URL. "
|
|
99
|
+
"Never table-only without links."
|
|
100
|
+
),
|
|
101
|
+
},
|
|
81
102
|
"verbs": {
|
|
82
|
-
"look": [
|
|
103
|
+
"look": [
|
|
104
|
+
"walla search <q> --json",
|
|
105
|
+
"walla search <q> --local --json",
|
|
106
|
+
"walla item <id> --json",
|
|
107
|
+
],
|
|
83
108
|
"talk": [
|
|
84
109
|
"walla negotiate <id> --json",
|
|
85
110
|
"walla inbox --json",
|
|
@@ -92,10 +117,14 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
92
117
|
],
|
|
93
118
|
},
|
|
94
119
|
"rules": [
|
|
120
|
+
"Always present Wallapop shortlists ranked best-first with markdown item links.",
|
|
95
121
|
"search / item / inbox / negotiate are safe reads or drafts.",
|
|
96
122
|
"say and offer require --yes / confirm=true; name listing + euros.",
|
|
97
123
|
"If only asked to look, do not send.",
|
|
98
124
|
"Ambiguous match or multiple GRABs: ask once with candidates.",
|
|
125
|
+
"Default search is all of Spain. Shipping is enough; do not fence on profile.km.",
|
|
126
|
+
"walla search --local only when the human asked nearby, pickup, or no shipping.",
|
|
127
|
+
"Keep model and size tokens in the query. Do not drop them to widen geography.",
|
|
99
128
|
"Refuse in-person offers outside pickup radius.",
|
|
100
129
|
"Refuse offer_eur above mandate.budget.",
|
|
101
130
|
"Negotiate drafts are win-win: respectful Spanish, no lowballs, no pressure.",
|
|
@@ -31,8 +31,8 @@ BANNED_PHRASES = (
|
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
_AGGRESSION_PCT: dict[Aggression, float] = {
|
|
34
|
-
"soft": 0.
|
|
35
|
-
"fair": 0.
|
|
34
|
+
"soft": 0.90,
|
|
35
|
+
"fair": 0.82,
|
|
36
36
|
"firm": 0.80,
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -82,17 +82,10 @@ def draft_negotiation(
|
|
|
82
82
|
f"Listing is PASS under mandate (must_match / budget / radius): {listing.title}"
|
|
83
83
|
)
|
|
84
84
|
walk = _walk_away(ask, profile)
|
|
85
|
-
offer = _first_offer(ask, walk,
|
|
85
|
+
offer = _first_offer(ask, walk, profile.aggressiveness)
|
|
86
86
|
specific = _specific_thing(listing)
|
|
87
87
|
greeting = f"Hola {seller_name}," if seller_name else "Hola,"
|
|
88
|
-
opening = (
|
|
89
|
-
f"{greeting} me interesa tu {specific}. "
|
|
90
|
-
"¿Sigue disponible? Puedo pasar hoy si te va bien."
|
|
91
|
-
)
|
|
92
|
-
offer_line = (
|
|
93
|
-
f"Gracias. Por el estado y lo que hay ahora mismo, te propongo "
|
|
94
|
-
f"{_fmt_eur(offer)} € y lo recojo cuando te venga bien."
|
|
95
|
-
)
|
|
88
|
+
opening, offer_line, you_give = _copy(greeting, specific, offer, _ships(listing))
|
|
96
89
|
return NegotiationBrief(
|
|
97
90
|
item_id=listing.id,
|
|
98
91
|
title=listing.title,
|
|
@@ -105,11 +98,37 @@ def draft_negotiation(
|
|
|
105
98
|
offer_line=offer_line,
|
|
106
99
|
why=_why(ask, offer, verdict, profile.aggressiveness),
|
|
107
100
|
seller_gives="Un precio justo y una venta rapida sin pelea.",
|
|
108
|
-
you_give=
|
|
101
|
+
you_give=you_give,
|
|
109
102
|
tone_checks=_tone_checks(opening, offer_line, offer, ask),
|
|
110
103
|
)
|
|
111
104
|
|
|
112
105
|
|
|
106
|
+
def _ships(listing: Listing) -> bool:
|
|
107
|
+
return bool(listing.user_allows_shipping or listing.shippable)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _copy(greeting: str, specific: str, offer: float, ships: bool) -> tuple[str, str, str]:
|
|
111
|
+
if ships:
|
|
112
|
+
opening = (
|
|
113
|
+
f"{greeting} me interesa tu {specific}. "
|
|
114
|
+
"¿Sigue disponible? Me iría bien envío Wallapop."
|
|
115
|
+
)
|
|
116
|
+
offer_line = (
|
|
117
|
+
f"Gracias. Por el estado y lo que hay ahora mismo, te propongo "
|
|
118
|
+
f"{_fmt_eur(offer)} € con envío Wallapop."
|
|
119
|
+
)
|
|
120
|
+
return opening, offer_line, "Envío Wallapop, pago claro, trato respetuoso."
|
|
121
|
+
opening = (
|
|
122
|
+
f"{greeting} me interesa tu {specific}. "
|
|
123
|
+
"¿Sigue disponible? Puedo pasar hoy si te va bien."
|
|
124
|
+
)
|
|
125
|
+
offer_line = (
|
|
126
|
+
f"Gracias. Por el estado y lo que hay ahora mismo, te propongo "
|
|
127
|
+
f"{_fmt_eur(offer)} € y lo recojo cuando te venga bien."
|
|
128
|
+
)
|
|
129
|
+
return opening, offer_line, "Recogida flexible, pago claro, trato respetuoso."
|
|
130
|
+
|
|
131
|
+
|
|
113
132
|
def _assert_reachable(listing: Listing, profile: Profile) -> None:
|
|
114
133
|
if listing.user_allows_shipping or listing.shippable:
|
|
115
134
|
return
|
|
@@ -140,17 +159,11 @@ def _walk_away(ask: float, profile: Profile) -> float:
|
|
|
140
159
|
return round(min(cap, ask), 2)
|
|
141
160
|
|
|
142
161
|
|
|
143
|
-
def _first_offer(
|
|
144
|
-
ask: float, walk: float, verdict: str, aggressiveness: Aggression
|
|
145
|
-
) -> float:
|
|
162
|
+
def _first_offer(ask: float, walk: float, aggressiveness: Aggression) -> float:
|
|
146
163
|
"""Aggression sets the ask percent; never below 80% on first message."""
|
|
147
164
|
if ask <= 0:
|
|
148
165
|
return 0.0
|
|
149
|
-
pct = _AGGRESSION_PCT.get(aggressiveness, 0.
|
|
150
|
-
if ask <= walk and verdict == "GRAB" and aggressiveness == "fair":
|
|
151
|
-
pct = max(pct, 0.95)
|
|
152
|
-
elif ask <= walk and verdict == "GRAB" and aggressiveness == "soft":
|
|
153
|
-
pct = 0.98
|
|
166
|
+
pct = _AGGRESSION_PCT.get(aggressiveness, 0.82)
|
|
154
167
|
raw = ask * pct
|
|
155
168
|
floor = ask * 0.80
|
|
156
169
|
offer = max(floor, min(raw, walk, ask))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Ranked shortlist for agents (pure; no HTTP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ("PRESENT_RULE", "rank_shortlist")
|
|
8
|
+
|
|
9
|
+
_VERDICT_RANK = {"GRAB": 0, "LOOK": 1, "PASS": 2}
|
|
10
|
+
|
|
11
|
+
PRESENT_RULE = (
|
|
12
|
+
"Always say these are Wallapop.es listings. Present a numbered shortlist "
|
|
13
|
+
"ranked best-first (GRAB before LOOK; lower EUR first). Each row must "
|
|
14
|
+
"include the full https://es.wallapop.com/item/… URL as a markdown link. "
|
|
15
|
+
"Never summarize without links."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def rank_shortlist(
|
|
20
|
+
listings: list[dict[str, Any]], *, limit: int = 12
|
|
21
|
+
) -> list[dict[str, Any]]:
|
|
22
|
+
"""Return ranked rows: GRAB then LOOK then PASS; cheaper first within band."""
|
|
23
|
+
scored: list[tuple[int, float, dict[str, Any]]] = []
|
|
24
|
+
for row in listings:
|
|
25
|
+
verdict = str(row.get("verdict") or "LOOK")
|
|
26
|
+
if verdict == "PASS":
|
|
27
|
+
continue
|
|
28
|
+
price = row.get("price") or {}
|
|
29
|
+
amount = float(price.get("amount") or 0) if isinstance(price, dict) else 0.0
|
|
30
|
+
scored.append((_VERDICT_RANK.get(verdict, 9), amount, row))
|
|
31
|
+
scored.sort(key=lambda t: (t[0], t[1]))
|
|
32
|
+
out: list[dict[str, Any]] = []
|
|
33
|
+
for i, (_, amount, row) in enumerate(scored[:limit], start=1):
|
|
34
|
+
loc = row.get("location") or {}
|
|
35
|
+
city = loc.get("city") if isinstance(loc, dict) else None
|
|
36
|
+
out.append(
|
|
37
|
+
{
|
|
38
|
+
"rank": i,
|
|
39
|
+
"id": row.get("id"),
|
|
40
|
+
"title": row.get("title"),
|
|
41
|
+
"price_eur": amount,
|
|
42
|
+
"verdict": row.get("verdict"),
|
|
43
|
+
"url": row.get("url"),
|
|
44
|
+
"city": city,
|
|
45
|
+
"shippable": bool(
|
|
46
|
+
row.get("user_allows_shipping") or row.get("shippable")
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
return out
|
|
@@ -8,6 +8,7 @@ from walla.models.listing import Listing
|
|
|
8
8
|
from walla.models.profile import Profile
|
|
9
9
|
|
|
10
10
|
__all__ = (
|
|
11
|
+
"geo_scope",
|
|
11
12
|
"haversine_km",
|
|
12
13
|
"matches_must",
|
|
13
14
|
"score_listing",
|
|
@@ -32,8 +33,26 @@ def matches_must(listing: Listing, profile: Profile) -> bool:
|
|
|
32
33
|
return all(tok in blob for tok in tokens)
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def
|
|
36
|
-
"""
|
|
36
|
+
def geo_scope(*, local: bool) -> dict[str, object]:
|
|
37
|
+
"""Search contract for agents. lat/lon on the wire is a ranking bias."""
|
|
38
|
+
if local:
|
|
39
|
+
return {
|
|
40
|
+
"scope": "local",
|
|
41
|
+
"distance_filter": True,
|
|
42
|
+
"note": "Human asked nearby or pickup. PASS outside profile.km.",
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
"scope": "spain",
|
|
46
|
+
"distance_filter": False,
|
|
47
|
+
"note": (
|
|
48
|
+
"Spain-wide. Shipping counts anywhere. "
|
|
49
|
+
"Use --local only if the human asked nearby, pickup, or no shipping."
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def score_listing(listing: Listing, profile: Profile, *, local: bool = False) -> str:
|
|
55
|
+
"""Return GRAB / LOOK / PASS. Distance fences only when local=True."""
|
|
37
56
|
if listing.reserved:
|
|
38
57
|
return "PASS"
|
|
39
58
|
if not matches_must(listing, profile):
|
|
@@ -41,14 +60,17 @@ def score_listing(listing: Listing, profile: Profile) -> str:
|
|
|
41
60
|
budget = profile.spend_cap
|
|
42
61
|
if budget is not None and listing.price.amount > budget:
|
|
43
62
|
return "PASS"
|
|
44
|
-
if
|
|
45
|
-
|
|
46
|
-
if la is not None and lo is not None:
|
|
47
|
-
dist = haversine_km(profile.lat, profile.lon, la, lo)
|
|
48
|
-
if dist > profile.km and not listing.user_allows_shipping:
|
|
49
|
-
return "PASS"
|
|
50
|
-
if budget is not None and listing.price.amount <= budget * 0.85 and dist <= profile.km:
|
|
51
|
-
return "GRAB"
|
|
63
|
+
if local and _outside_km(listing, profile):
|
|
64
|
+
return "PASS"
|
|
52
65
|
if budget is not None and listing.price.amount <= budget * 0.9:
|
|
53
66
|
return "GRAB"
|
|
54
67
|
return "LOOK"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _outside_km(listing: Listing, profile: Profile) -> bool:
|
|
71
|
+
if profile.lat is None or profile.lon is None or not listing.location:
|
|
72
|
+
return False
|
|
73
|
+
la, lo = listing.location.latitude, listing.location.longitude
|
|
74
|
+
if la is None or lo is None:
|
|
75
|
+
return False
|
|
76
|
+
return haversine_km(profile.lat, profile.lon, la, lo) > profile.km
|
|
@@ -10,7 +10,8 @@ from walla.account.login import login_cookie_text, whoami
|
|
|
10
10
|
from walla.core.envelope import error_payload, success_payload
|
|
11
11
|
from walla.core.instruct import instruct_recipe
|
|
12
12
|
from walla.hunter.profile_store import load_profile
|
|
13
|
-
from walla.hunter.
|
|
13
|
+
from walla.hunter.shortlist import PRESENT_RULE, rank_shortlist
|
|
14
|
+
from walla.hunter.verdict import geo_scope, score_listing
|
|
14
15
|
from walla.search.api import get_categories, get_item, search_listings
|
|
15
16
|
|
|
16
17
|
__all__ = ("build_server",)
|
|
@@ -38,6 +39,7 @@ def build_server() -> Any:
|
|
|
38
39
|
max_results: int = 40,
|
|
39
40
|
min_price: float | None = None,
|
|
40
41
|
max_price: float | None = None,
|
|
42
|
+
local: bool = False,
|
|
41
43
|
) -> dict[str, Any]:
|
|
42
44
|
def _run() -> dict[str, Any]:
|
|
43
45
|
profile = load_profile()
|
|
@@ -56,9 +58,17 @@ def build_server() -> Any:
|
|
|
56
58
|
)
|
|
57
59
|
rows = []
|
|
58
60
|
for listing in result.listings:
|
|
59
|
-
listing.verdict = score_listing(listing, profile)
|
|
61
|
+
listing.verdict = score_listing(listing, profile, local=local)
|
|
60
62
|
rows.append(listing.model_dump(mode="json"))
|
|
61
|
-
|
|
63
|
+
shortlist = rank_shortlist(rows)
|
|
64
|
+
return {
|
|
65
|
+
"count": len(rows),
|
|
66
|
+
"source": "wallapop.es",
|
|
67
|
+
"geo": geo_scope(local=local),
|
|
68
|
+
"shortlist": shortlist,
|
|
69
|
+
"listings": rows,
|
|
70
|
+
"hitl": {"present": PRESENT_RULE},
|
|
71
|
+
}
|
|
62
72
|
|
|
63
73
|
return _wrap(_run)
|
|
64
74
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|