walla-cli 0.1.10__tar.gz → 0.1.11__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.10 → walla_cli-0.1.11}/PKG-INFO +1 -1
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/WIRE.md +5 -0
- walla_cli-0.1.11/fixtures/sell_suggest_draft.json +23 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/pyproject.toml +1 -1
- {walla_cli-0.1.10 → walla_cli-0.1.11}/skills/walla-sell/SKILL.md +9 -8
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_live.py +2 -1
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_sell.py +14 -13
- walla_cli-0.1.11/tests/test_suggest_watch.py +101 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_wire_contracts.py +3 -2
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/__init__.py +1 -1
- walla_cli-0.1.11/walla/account/desk_watch.py +36 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/sell.py +25 -58
- walla_cli-0.1.11/walla/account/sell_steps.py +82 -0
- walla_cli-0.1.11/walla/account/sell_suggest.py +77 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_desk.py +12 -2
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_sell.py +34 -8
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/instruct.py +4 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/mcp/server.py +10 -2
- {walla_cli-0.1.10 → walla_cli-0.1.11}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/.gitignore +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/AGENTS.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/LICENSE +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/README.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-demo-poster.jpg +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-demo-preview.gif +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-demo.mp4 +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/code_quality.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/mcp.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/objective_function.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/categories.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/offer_buyer_request.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/sell_item_request.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/wire_contracts.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/scripts/check_code_quality.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/scripts/dogfood_run.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/skills/walla/SKILL.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/skills/walla-buy/SKILL.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_account.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_account_more.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_catch.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_categories_find.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_chat_open.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_core.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_desk.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_edges.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_export.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_negotiate.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_parsers.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_polite.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_regressions.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_session_store.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_shortlist.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_verdict.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/uv.lock +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/actions.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/chat.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/cookies.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/desk.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/inbox.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/login.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/offer_wire.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/prompt.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/sell_wire.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/banner.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_account.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_look.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_ops.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/console.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/main.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/errors.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/path.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/client.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/headers.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/polite.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/converge.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/export.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/export_pdf.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/negotiate.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/profile_store.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/pursuits.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/shortlist.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/stack.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/verdict.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/account.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/listing.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/profile.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/api.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/category_find.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/parse.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/pick_category.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.11
|
|
4
4
|
Summary: Wallapop.es CLI - look · offer · talk · sell 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
|
|
@@ -34,6 +34,11 @@ Public URL: `https://es.wallapop.com/item/<web_slug>`.
|
|
|
34
34
|
`leaf_id` + `root_id`. Sell accepts `--category <leaf-or-name>`; `--root` is
|
|
35
35
|
optional.
|
|
36
36
|
|
|
37
|
+
After `current_step=photo`, the steps `draft` may include `category_leaf_id`
|
|
38
|
+
(title+photo hint). `GET /api/v3/suggested-item-data/{upload_id}` often returns
|
|
39
|
+
empty HTTP 200; still poll before `loading`. CLI: `walla sell … --title … --suggest`.
|
|
40
|
+
`walla desk --watch` polls the inbox until `stack.best` converges.
|
|
41
|
+
|
|
37
42
|
## Auth
|
|
38
43
|
|
|
39
44
|
`POST https://api.wallapop.com/api/v3/access/login` with
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"note": "After photo step, Wallapop may inject category_leaf_id from title+photos. suggested-item-data often returns empty HTTP 200.",
|
|
4
|
+
"after_photo": {
|
|
5
|
+
"step": {"id": "category", "components": []},
|
|
6
|
+
"draft": {
|
|
7
|
+
"title": "Escritorio madera oscura patas cromadas",
|
|
8
|
+
"category_leaf_id": "24208"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"after_loading": {
|
|
12
|
+
"step": {"id": "listing", "components": []},
|
|
13
|
+
"draft": {
|
|
14
|
+
"title": "Escritorio madera oscura patas cromadas",
|
|
15
|
+
"category_leaf_id": "24208",
|
|
16
|
+
"root_category_id": "12467"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"suggested_item_data": {
|
|
20
|
+
"http_status": 200,
|
|
21
|
+
"body": null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -14,10 +14,11 @@ Do not ask the human to type buyer replies. Desk sends those.
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
walla categories --find escritorio --json
|
|
17
|
-
walla sell ./shot1.jpg ./shot2.jpg --json
|
|
17
|
+
walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
`--suggest` (or draft with `--title` and no `--category`) asks Wallapop to prefill
|
|
21
|
+
`category_leaf_id` from title+photos. Ask remaining `data.questions`, then:
|
|
21
22
|
|
|
22
23
|
```bash
|
|
23
24
|
walla sell ./shot1.jpg ./shot2.jpg \
|
|
@@ -26,17 +27,17 @@ walla sell ./shot1.jpg ./shot2.jpg \
|
|
|
26
27
|
--floor 35 --yes --json
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
`--category` accepts a leaf id **or** name tokens
|
|
30
|
-
`--
|
|
31
|
-
|
|
30
|
+
`--category` accepts a leaf id **or** name tokens. `--root` is optional.
|
|
31
|
+
`--floor` is the lowest EUR desk may accept.
|
|
32
|
+
|
|
32
33
|
Then:
|
|
33
34
|
|
|
34
35
|
```bash
|
|
35
|
-
walla desk --json
|
|
36
|
+
walla desk --watch --seconds 45 --rounds 12 --json
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
Desk
|
|
39
|
-
|
|
39
|
+
Desk answers buyers until `stack.best` converges or rounds end. Show the title,
|
|
40
|
+
agreed EUR, `stack.why`, and the item link.
|
|
40
41
|
|
|
41
42
|
The human accepts the sale in the Wallapop app. `walla unsell <id> --yes` deletes
|
|
42
43
|
a listing. Never mark the item sold from walla.
|
|
@@ -11,7 +11,8 @@ import pytest
|
|
|
11
11
|
from walla.account.chat import open_conversation
|
|
12
12
|
from walla.account.inbox import list_conversations_raw
|
|
13
13
|
from walla.account.login import ensure_access_token, whoami
|
|
14
|
-
from walla.account.sell import
|
|
14
|
+
from walla.account.sell import delete_listing, publish_listing, upload_pictures
|
|
15
|
+
from walla.account.sell_steps import post_step as _step
|
|
15
16
|
from walla.account.session_store import load_session
|
|
16
17
|
from walla.http.client import HttpClient
|
|
17
18
|
from walla.http.polite import reset_polite
|
|
@@ -119,23 +119,23 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
|
|
|
119
119
|
photo.write_bytes(b"\xff\xd8\xff\xd9")
|
|
120
120
|
fake_http = MagicMock()
|
|
121
121
|
fake_http.post.side_effect = [
|
|
122
|
-
{"step": {"id": "title"}},
|
|
123
|
-
{"step": {"id": "photo", "
|
|
124
|
-
{"step": {"id": "category"}},
|
|
122
|
+
{"step": {"id": "title"}, "draft": {}},
|
|
123
|
+
{"step": {"id": "photo"}, "draft": {"title": "Test item"}},
|
|
125
124
|
{
|
|
126
|
-
"step": {
|
|
127
|
-
|
|
128
|
-
"poll_before_next_step": {"path": "/api/v3/suggested-item-data/x"},
|
|
129
|
-
}
|
|
125
|
+
"step": {"id": "category"},
|
|
126
|
+
"draft": {"title": "Test item", "category_leaf_id": "10105"},
|
|
130
127
|
},
|
|
131
|
-
{"step": {"id": "
|
|
128
|
+
{"step": {"id": "loading"}, "draft": {"title": "Test item"}},
|
|
129
|
+
{"step": {"id": "listing"}, "draft": {"title": "Test item"}},
|
|
132
130
|
]
|
|
133
131
|
fake_http.get.return_value = {}
|
|
134
132
|
listing = MagicMock()
|
|
135
133
|
listing.url = "https://es.wallapop.com/item/test-item-99"
|
|
136
134
|
listing.web_slug = "test-item-99"
|
|
137
135
|
with (
|
|
138
|
-
patch("walla.account.sell.
|
|
136
|
+
patch("walla.account.sell.auth_client", return_value=fake_http),
|
|
137
|
+
patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
|
|
138
|
+
patch("walla.account.sell.poll_suggested", return_value=None),
|
|
139
139
|
patch("walla.account.sell.upload_pictures", return_value=1),
|
|
140
140
|
patch(
|
|
141
141
|
"walla.account.sell.create_listing",
|
|
@@ -156,7 +156,6 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
|
|
|
156
156
|
assert out["id"] == "abc123"
|
|
157
157
|
assert out["photos"] == 1
|
|
158
158
|
assert out["url"] == "https://es.wallapop.com/item/test-item-99"
|
|
159
|
-
assert fake_http.post.call_count == 5
|
|
160
159
|
|
|
161
160
|
|
|
162
161
|
def test_create_listing_multipart(tmp_path: Path) -> None:
|
|
@@ -179,7 +178,7 @@ def test_create_listing_multipart(tmp_path: Path) -> None:
|
|
|
179
178
|
fake_session = MagicMock()
|
|
180
179
|
fake_session.request.return_value = fake_resp
|
|
181
180
|
with (
|
|
182
|
-
patch("walla.account.sell.
|
|
181
|
+
patch("walla.account.sell.auth_headers", return_value={"Authorization": "Bearer x"}),
|
|
183
182
|
patch("walla.account.sell.requests.Session", return_value=fake_session),
|
|
184
183
|
patch("walla.account.sell.CurlMime") as mime_cls,
|
|
185
184
|
patch("walla.account.sell.wait_turn"),
|
|
@@ -227,14 +226,16 @@ def test_publish_listing_two_photos(tmp_path: Path) -> None:
|
|
|
227
226
|
listing.url = "https://es.wallapop.com/item/test-item-99"
|
|
228
227
|
listing.web_slug = "test-item-99"
|
|
229
228
|
with (
|
|
230
|
-
patch("walla.account.sell.
|
|
229
|
+
patch("walla.account.sell.auth_client", return_value=fake_http),
|
|
230
|
+
patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
|
|
231
|
+
patch("walla.account.sell.poll_suggested", return_value=None),
|
|
231
232
|
patch("walla.account.sell.upload_pictures", return_value=2),
|
|
232
233
|
patch(
|
|
233
234
|
"walla.account.sell.create_listing",
|
|
234
235
|
return_value={"id": "abc123", "flags": {}},
|
|
235
236
|
),
|
|
236
237
|
patch("walla.account.sell.get_item", return_value=listing),
|
|
237
|
-
patch("walla.account.sell.
|
|
238
|
+
patch("walla.account.sell.auth_headers", return_value={}),
|
|
238
239
|
patch("walla.account.sell.requests.Session", return_value=fake_session),
|
|
239
240
|
patch("walla.account.sell.CurlMime") as mime_cls,
|
|
240
241
|
patch("walla.account.sell.wait_turn"),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Suggest draft parse + desk watch stop conditions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
from walla.account.desk_watch import run_desk_watch
|
|
10
|
+
from walla.account.sell_steps import parse_steps_draft
|
|
11
|
+
|
|
12
|
+
FIX = Path(__file__).resolve().parents[1] / "fixtures"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_parse_steps_draft_fixture() -> None:
|
|
16
|
+
raw = json.loads((FIX / "sell_suggest_draft.json").read_text(encoding="utf-8"))
|
|
17
|
+
after = parse_steps_draft(raw["after_photo"])
|
|
18
|
+
assert after["category_leaf_id"] == "24208"
|
|
19
|
+
assert after["title"].startswith("Escritorio")
|
|
20
|
+
loaded = parse_steps_draft(raw["after_loading"])
|
|
21
|
+
assert loaded["root_category_id"] == "12467"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_desk_watch_stops_on_converged() -> None:
|
|
25
|
+
converged = {
|
|
26
|
+
"replies": [],
|
|
27
|
+
"stack": {"best": {"status": "converged", "title": "X", "agreed_eur": 40}},
|
|
28
|
+
"close_in_app": True,
|
|
29
|
+
}
|
|
30
|
+
with patch("walla.account.desk_watch.run_desk", return_value=converged) as desk:
|
|
31
|
+
with patch("walla.account.desk_watch.time.sleep") as slept:
|
|
32
|
+
out = run_desk_watch(seconds=5, rounds=5)
|
|
33
|
+
assert out["watch_stopped"] == "converged"
|
|
34
|
+
assert out["watch_round"] == 1
|
|
35
|
+
desk.assert_called_once()
|
|
36
|
+
slept.assert_not_called()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_desk_watch_max_rounds() -> None:
|
|
40
|
+
waiting = {
|
|
41
|
+
"replies": [],
|
|
42
|
+
"stack": {"best": None},
|
|
43
|
+
"close_in_app": True,
|
|
44
|
+
}
|
|
45
|
+
with patch("walla.account.desk_watch.run_desk", return_value=waiting) as desk:
|
|
46
|
+
with patch("walla.account.desk_watch.time.sleep") as slept:
|
|
47
|
+
out = run_desk_watch(seconds=5, rounds=3)
|
|
48
|
+
assert out["watch_stopped"] == "max_rounds"
|
|
49
|
+
assert out["watch_round"] == 3
|
|
50
|
+
assert desk.call_count == 3
|
|
51
|
+
assert slept.call_count == 2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_suggest_from_photos_mocked(tmp_path: Path) -> None:
|
|
55
|
+
from walla.account.sell_suggest import suggest_from_photos
|
|
56
|
+
|
|
57
|
+
photo = tmp_path / "a.jpg"
|
|
58
|
+
photo.write_bytes(b"\xff\xd8\xff\xd9")
|
|
59
|
+
steps = [
|
|
60
|
+
{"step": {"id": "title"}, "draft": {}},
|
|
61
|
+
{"step": {"id": "photo"}, "draft": {"title": "Escritorio madera"}},
|
|
62
|
+
{
|
|
63
|
+
"step": {"id": "category"},
|
|
64
|
+
"draft": {"title": "Escritorio madera", "category_leaf_id": "24208"},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"step": {"id": "loading"},
|
|
68
|
+
"draft": {
|
|
69
|
+
"title": "Escritorio madera",
|
|
70
|
+
"category_leaf_id": "24208",
|
|
71
|
+
"root_category_id": "12467",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"step": {"id": "listing"},
|
|
76
|
+
"draft": {
|
|
77
|
+
"title": "Escritorio madera",
|
|
78
|
+
"category_leaf_id": "24208",
|
|
79
|
+
"root_category_id": "12467",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
fake = MagicMock()
|
|
84
|
+
with (
|
|
85
|
+
patch("walla.account.sell_suggest.auth_client", return_value=fake),
|
|
86
|
+
patch("walla.account.sell_suggest.post_step", side_effect=steps),
|
|
87
|
+
patch("walla.account.sell_suggest.upload_pictures", return_value=1),
|
|
88
|
+
patch("walla.account.sell_suggest.poll_suggested", return_value=None),
|
|
89
|
+
patch(
|
|
90
|
+
"walla.account.sell_suggest.get_categories",
|
|
91
|
+
return_value=__import__(
|
|
92
|
+
"walla.search.parse", fromlist=["parse_categories"]
|
|
93
|
+
).parse_categories(
|
|
94
|
+
json.loads((FIX / "categories.json").read_text(encoding="utf-8"))
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
):
|
|
98
|
+
out = suggest_from_photos([photo], title="Escritorio madera")
|
|
99
|
+
assert out["category_leaf_id"] == "24208"
|
|
100
|
+
assert out["root_category_id"] == "12467"
|
|
101
|
+
assert "Escritorios" in (out.get("path") or "")
|
|
@@ -250,8 +250,9 @@ def test_every_contract_is_sent(
|
|
|
250
250
|
expires_at=9_999_999_999,
|
|
251
251
|
)
|
|
252
252
|
monkeypatch.setattr("walla.account.login.ensure_access_token", lambda: sess)
|
|
253
|
-
monkeypatch.setattr("walla.account.
|
|
254
|
-
monkeypatch.setattr("walla.account.sell.
|
|
253
|
+
monkeypatch.setattr("walla.account.sell_steps.ensure_access_token", lambda: sess)
|
|
254
|
+
monkeypatch.setattr("walla.account.sell.auth_client", lambda: rec)
|
|
255
|
+
monkeypatch.setattr("walla.account.sell_steps.auth_client", lambda: rec)
|
|
255
256
|
monkeypatch.setattr("walla.account.chat.ensure_access_token", _must_not_mint)
|
|
256
257
|
monkeypatch.setattr(
|
|
257
258
|
"walla.account.actions.load_profile",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Poll the inbox until a deal converges or rounds run out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from walla.account.desk import run_desk
|
|
9
|
+
from walla.http.client import HttpClient
|
|
10
|
+
|
|
11
|
+
__all__ = ("run_desk_watch",)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_desk_watch(
|
|
15
|
+
*,
|
|
16
|
+
seconds: float = 45.0,
|
|
17
|
+
rounds: int = 12,
|
|
18
|
+
client: HttpClient | None = None,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Repeat run_desk until stack.best is converged or max rounds."""
|
|
21
|
+
if seconds < 5:
|
|
22
|
+
raise ValueError("--seconds must be >= 5")
|
|
23
|
+
if rounds < 1 or rounds > 60:
|
|
24
|
+
raise ValueError("--rounds must be 1..60")
|
|
25
|
+
last: dict[str, Any] = {}
|
|
26
|
+
for i in range(rounds):
|
|
27
|
+
last = run_desk(client=client)
|
|
28
|
+
last["watch_round"] = i + 1
|
|
29
|
+
best = (last.get("stack") or {}).get("best")
|
|
30
|
+
if isinstance(best, dict) and best.get("status") == "converged":
|
|
31
|
+
last["watch_stopped"] = "converged"
|
|
32
|
+
return last
|
|
33
|
+
if i + 1 < rounds:
|
|
34
|
+
time.sleep(float(seconds))
|
|
35
|
+
last["watch_stopped"] = "max_rounds"
|
|
36
|
+
return last
|
|
@@ -9,7 +9,13 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
from curl_cffi import CurlMime, requests
|
|
11
11
|
|
|
12
|
-
from walla.account.
|
|
12
|
+
from walla.account.sell_steps import (
|
|
13
|
+
auth_client,
|
|
14
|
+
auth_headers,
|
|
15
|
+
parse_steps_draft,
|
|
16
|
+
poll_suggested,
|
|
17
|
+
post_step,
|
|
18
|
+
)
|
|
13
19
|
from walla.account.sell_wire import build_item_body
|
|
14
20
|
from walla.core.exceptions import (
|
|
15
21
|
WallaHTTPError,
|
|
@@ -18,7 +24,7 @@ from walla.core.exceptions import (
|
|
|
18
24
|
WallaUnsupportedError,
|
|
19
25
|
)
|
|
20
26
|
from walla.http.client import HttpClient
|
|
21
|
-
from walla.http.headers import API_BASE, WEB_BASE
|
|
27
|
+
from walla.http.headers import API_BASE, WEB_BASE
|
|
22
28
|
from walla.http.polite import wait_turn
|
|
23
29
|
from walla.search.api import get_item
|
|
24
30
|
from walla.search.parse import item_url
|
|
@@ -39,42 +45,11 @@ _IMAGE_TYPES = {
|
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
|
|
42
|
-
def _auth_client() -> HttpClient:
|
|
43
|
-
sess = ensure_access_token()
|
|
44
|
-
return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _auth_headers() -> dict[str, str]:
|
|
48
|
-
sess = ensure_access_token()
|
|
49
|
-
headers = default_headers(device_id=sess.device_id)
|
|
50
|
-
headers["Authorization"] = f"Bearer {sess.access_token}"
|
|
51
|
-
return headers
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _step(
|
|
55
|
-
http: HttpClient,
|
|
56
|
-
upload_id: str,
|
|
57
|
-
*,
|
|
58
|
-
current: str | None,
|
|
59
|
-
draft: dict[str, Any],
|
|
60
|
-
) -> dict[str, Any]:
|
|
61
|
-
body: dict[str, Any] = {
|
|
62
|
-
"mode": {"action": "upload", "id": upload_id},
|
|
63
|
-
"draft": draft,
|
|
64
|
-
}
|
|
65
|
-
if current:
|
|
66
|
-
body["current_step"] = current
|
|
67
|
-
raw = http.post("/api/v3/steps", json_body=body, auth=True)
|
|
68
|
-
if not isinstance(raw, dict):
|
|
69
|
-
raise WallaUnsupportedError("steps returned non-object")
|
|
70
|
-
return raw
|
|
71
|
-
|
|
72
|
-
|
|
73
48
|
def upload_pictures(upload_id: str, photos: list[Path]) -> int:
|
|
74
49
|
"""POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
|
|
75
50
|
if not photos:
|
|
76
51
|
raise ValueError("at least one photo required")
|
|
77
|
-
headers = {k: v for k, v in
|
|
52
|
+
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
78
53
|
session: Any = requests.Session()
|
|
79
54
|
for path in photos:
|
|
80
55
|
data = path.read_bytes()
|
|
@@ -101,7 +76,7 @@ def upload_pictures(upload_id: str, photos: list[Path]) -> int:
|
|
|
101
76
|
|
|
102
77
|
def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
|
|
103
78
|
"""Multipart POST /api/v3/items with Accept upload-v2."""
|
|
104
|
-
headers = {k: v for k, v in
|
|
79
|
+
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
105
80
|
headers["Accept"] = _UPLOAD_ACCEPT
|
|
106
81
|
data = first_photo.read_bytes()
|
|
107
82
|
ctype = _IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
|
|
@@ -130,7 +105,7 @@ def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
|
|
|
130
105
|
|
|
131
106
|
|
|
132
107
|
def delete_listing(item_id: str, *, client: HttpClient | None = None) -> dict[str, Any]:
|
|
133
|
-
http = client or
|
|
108
|
+
http = client or auth_client()
|
|
134
109
|
http.delete(f"/api/v3/items/{item_id}", auth=True)
|
|
135
110
|
return {"id": item_id, "deleted": True}
|
|
136
111
|
|
|
@@ -154,34 +129,26 @@ def publish_listing(
|
|
|
154
129
|
for p in paths:
|
|
155
130
|
if not p.is_file():
|
|
156
131
|
raise ValueError(f"photo not found: {p}")
|
|
157
|
-
http =
|
|
132
|
+
http = auth_client()
|
|
158
133
|
upload_id = str(uuid.uuid4())
|
|
159
134
|
draft: dict[str, Any] = {"title": title.strip()[:50]}
|
|
160
|
-
|
|
161
|
-
|
|
135
|
+
post_step(http, upload_id, current=None, draft={})
|
|
136
|
+
post_step(http, upload_id, current="title", draft=draft)
|
|
162
137
|
upload_pictures(upload_id, paths)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
wait_turn()
|
|
172
|
-
try:
|
|
173
|
-
http.get(f"/api/v3/suggested-item-data/{upload_id}", auth=True)
|
|
174
|
-
break
|
|
175
|
-
except WallaHTTPError as exc:
|
|
176
|
-
if exc.status_code != 404:
|
|
177
|
-
raise
|
|
178
|
-
_step(http, upload_id, current="loading", draft=draft)
|
|
138
|
+
after_photo = post_step(http, upload_id, current="photo", draft=draft)
|
|
139
|
+
wire = parse_steps_draft(after_photo)
|
|
140
|
+
leaf = str(category_leaf_id)
|
|
141
|
+
root = str(root_category_id)
|
|
142
|
+
draft = {**draft, "category_leaf_id": leaf, "root_category_id": root}
|
|
143
|
+
post_step(http, upload_id, current="category", draft=draft)
|
|
144
|
+
poll_suggested(http, upload_id)
|
|
145
|
+
post_step(http, upload_id, current="loading", draft=draft)
|
|
179
146
|
item = build_item_body(
|
|
180
147
|
upload_id=upload_id,
|
|
181
148
|
title=title,
|
|
182
149
|
description=description,
|
|
183
150
|
price_eur=price_eur,
|
|
184
|
-
category_leaf_id=
|
|
151
|
+
category_leaf_id=leaf,
|
|
185
152
|
lat=lat,
|
|
186
153
|
lon=lon,
|
|
187
154
|
condition=condition,
|
|
@@ -197,11 +164,11 @@ def publish_listing(
|
|
|
197
164
|
"title": title.strip()[:50],
|
|
198
165
|
"url": _public_item_url(str(created["id"])),
|
|
199
166
|
"photos": len(paths),
|
|
167
|
+
"wire_draft": wire,
|
|
200
168
|
}
|
|
201
169
|
|
|
202
170
|
|
|
203
171
|
def _public_item_url(item_id: str) -> str:
|
|
204
|
-
"""Wallapop web needs the slug path, not the opaque item hash."""
|
|
205
172
|
try:
|
|
206
173
|
listing = get_item(item_id)
|
|
207
174
|
except (
|
|
@@ -222,7 +189,7 @@ def _public_item_url(item_id: str) -> str:
|
|
|
222
189
|
|
|
223
190
|
|
|
224
191
|
def _upload_extra_pictures(item_id: str, photos: list[Path]) -> None:
|
|
225
|
-
headers = {k: v for k, v in
|
|
192
|
+
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
226
193
|
headers["Accept"] = _UPLOAD_ACCEPT
|
|
227
194
|
session: Any = requests.Session()
|
|
228
195
|
for idx, path in enumerate(photos, start=1):
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Shared Vender step helpers (auth + /api/v3/steps + suggest poll)."""
|
|
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 WallaHTTPError, WallaUnsupportedError
|
|
9
|
+
from walla.http.client import HttpClient
|
|
10
|
+
from walla.http.headers import default_headers
|
|
11
|
+
from walla.http.polite import wait_turn
|
|
12
|
+
|
|
13
|
+
__all__ = (
|
|
14
|
+
"auth_client",
|
|
15
|
+
"auth_headers",
|
|
16
|
+
"parse_steps_draft",
|
|
17
|
+
"poll_suggested",
|
|
18
|
+
"post_step",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def auth_client() -> HttpClient:
|
|
23
|
+
sess = ensure_access_token()
|
|
24
|
+
return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def auth_headers() -> dict[str, str]:
|
|
28
|
+
sess = ensure_access_token()
|
|
29
|
+
headers = default_headers(device_id=sess.device_id)
|
|
30
|
+
headers["Authorization"] = f"Bearer {sess.access_token}"
|
|
31
|
+
return headers
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def post_step(
|
|
35
|
+
http: HttpClient,
|
|
36
|
+
upload_id: str,
|
|
37
|
+
*,
|
|
38
|
+
current: str | None,
|
|
39
|
+
draft: dict[str, Any],
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
body: dict[str, Any] = {
|
|
42
|
+
"mode": {"action": "upload", "id": upload_id},
|
|
43
|
+
"draft": draft,
|
|
44
|
+
}
|
|
45
|
+
if current:
|
|
46
|
+
body["current_step"] = current
|
|
47
|
+
raw = http.post("/api/v3/steps", json_body=body, auth=True)
|
|
48
|
+
if not isinstance(raw, dict):
|
|
49
|
+
raise WallaUnsupportedError("steps returned non-object")
|
|
50
|
+
return raw
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def poll_suggested(http: HttpClient, upload_id: str, *, attempts: int = 8) -> Any:
|
|
54
|
+
"""GET suggested-item-data until non-404 (body often empty 200)."""
|
|
55
|
+
last: Any = None
|
|
56
|
+
for _ in range(attempts):
|
|
57
|
+
wait_turn()
|
|
58
|
+
try:
|
|
59
|
+
last = http.get(f"/api/v3/suggested-item-data/{upload_id}", auth=True)
|
|
60
|
+
return last
|
|
61
|
+
except WallaHTTPError as exc:
|
|
62
|
+
if exc.status_code != 404:
|
|
63
|
+
raise
|
|
64
|
+
return last
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_steps_draft(payload: dict[str, Any]) -> dict[str, str]:
|
|
68
|
+
"""Pull title/category ids from a steps response draft."""
|
|
69
|
+
draft = payload.get("draft")
|
|
70
|
+
if not isinstance(draft, dict):
|
|
71
|
+
return {}
|
|
72
|
+
out: dict[str, str] = {}
|
|
73
|
+
title = draft.get("title")
|
|
74
|
+
if title:
|
|
75
|
+
out["title"] = str(title).strip()[:50]
|
|
76
|
+
leaf = draft.get("category_leaf_id") or draft.get("categoryLeafId")
|
|
77
|
+
if leaf:
|
|
78
|
+
out["category_leaf_id"] = str(leaf)
|
|
79
|
+
root = draft.get("root_category_id") or draft.get("rootCategoryId")
|
|
80
|
+
if root:
|
|
81
|
+
out["root_category_id"] = str(root)
|
|
82
|
+
return out
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Ask Wallapop to prefill category from title+photos (steps draft)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from walla.account.sell import upload_pictures
|
|
10
|
+
from walla.account.sell_steps import (
|
|
11
|
+
auth_client,
|
|
12
|
+
parse_steps_draft,
|
|
13
|
+
poll_suggested,
|
|
14
|
+
post_step,
|
|
15
|
+
)
|
|
16
|
+
from walla.search.api import get_categories
|
|
17
|
+
from walla.search.category_find import flatten_categories, resolve_root_id
|
|
18
|
+
|
|
19
|
+
__all__ = ("suggest_from_photos",)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def suggest_from_photos(
|
|
23
|
+
photos: list[Path],
|
|
24
|
+
*,
|
|
25
|
+
title: str,
|
|
26
|
+
root_category_id: str | None = None,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Run title→photos→photo; return draft fields Wallapop injected."""
|
|
29
|
+
paths = [Path(p) for p in photos]
|
|
30
|
+
for p in paths:
|
|
31
|
+
if not p.is_file():
|
|
32
|
+
raise ValueError(f"photo not found: {p}")
|
|
33
|
+
text = title.strip()[:50]
|
|
34
|
+
if not text:
|
|
35
|
+
raise ValueError("title required for suggest")
|
|
36
|
+
http = auth_client()
|
|
37
|
+
upload_id = str(uuid.uuid4())
|
|
38
|
+
draft: dict[str, Any] = {"title": text}
|
|
39
|
+
post_step(http, upload_id, current=None, draft={})
|
|
40
|
+
post_step(http, upload_id, current="title", draft=draft)
|
|
41
|
+
upload_pictures(upload_id, paths)
|
|
42
|
+
after_photo = post_step(http, upload_id, current="photo", draft=draft)
|
|
43
|
+
parsed = parse_steps_draft(after_photo)
|
|
44
|
+
leaf = parsed.get("category_leaf_id")
|
|
45
|
+
root = parsed.get("root_category_id") or root_category_id
|
|
46
|
+
path = None
|
|
47
|
+
if leaf:
|
|
48
|
+
roots = get_categories()
|
|
49
|
+
if not root:
|
|
50
|
+
root = resolve_root_id(roots, leaf)
|
|
51
|
+
path = next(
|
|
52
|
+
(h.path for h in flatten_categories(roots) if h.leaf_id == leaf),
|
|
53
|
+
None,
|
|
54
|
+
)
|
|
55
|
+
draft = {
|
|
56
|
+
**draft,
|
|
57
|
+
"category_leaf_id": leaf,
|
|
58
|
+
"root_category_id": str(root),
|
|
59
|
+
}
|
|
60
|
+
post_step(http, upload_id, current="category", draft=draft)
|
|
61
|
+
suggested_body = poll_suggested(http, upload_id)
|
|
62
|
+
after_load = post_step(http, upload_id, current="loading", draft=draft)
|
|
63
|
+
parsed = {**parsed, **parse_steps_draft(after_load)}
|
|
64
|
+
else:
|
|
65
|
+
suggested_body = None
|
|
66
|
+
return {
|
|
67
|
+
"upload_id": upload_id,
|
|
68
|
+
"title": parsed.get("title") or text,
|
|
69
|
+
"category_leaf_id": leaf,
|
|
70
|
+
"root_category_id": str(root) if root else None,
|
|
71
|
+
"path": path,
|
|
72
|
+
"suggested_item_data": suggested_body,
|
|
73
|
+
"hint": (
|
|
74
|
+
"Wallapop filled category_leaf_id from title+photos when present. "
|
|
75
|
+
"Pass --category / --title overrides, then --yes to publish."
|
|
76
|
+
),
|
|
77
|
+
}
|
|
@@ -7,6 +7,7 @@ from typing import Any
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
9
|
from walla.account.desk import run_desk, run_pursue
|
|
10
|
+
from walla.account.desk_watch import run_desk_watch
|
|
10
11
|
from walla.cli.catch import run_cmd
|
|
11
12
|
|
|
12
13
|
__all__ = ("register",)
|
|
@@ -28,10 +29,19 @@ def register(app: typer.Typer) -> None:
|
|
|
28
29
|
run_cmd(_run, as_json=json)
|
|
29
30
|
|
|
30
31
|
@app.command("desk")
|
|
31
|
-
def desk_cmd(
|
|
32
|
-
|
|
32
|
+
def desk_cmd(
|
|
33
|
+
watch: bool = typer.Option(
|
|
34
|
+
False, "--watch", help="Poll inbox until a price converges"
|
|
35
|
+
),
|
|
36
|
+
seconds: float = typer.Option(45.0, "--seconds", help="Sleep between watch rounds"),
|
|
37
|
+
rounds: int = typer.Option(12, "--rounds", help="Max watch rounds (1..60)"),
|
|
38
|
+
json: bool = typer.Option(False, "--json"),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Read the inbox, answer open threads, summarize the best price."""
|
|
33
41
|
|
|
34
42
|
def _run() -> dict[str, Any]:
|
|
43
|
+
if watch:
|
|
44
|
+
return run_desk_watch(seconds=seconds, rounds=rounds)
|
|
35
45
|
return run_desk()
|
|
36
46
|
|
|
37
47
|
run_cmd(_run, as_json=json)
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
10
|
from walla.account.sell import delete_listing, publish_listing
|
|
11
|
+
from walla.account.sell_suggest import suggest_from_photos
|
|
11
12
|
from walla.account.sell_wire import CONDITIONS, SELL_QUESTIONS, missing_sell_fields
|
|
12
13
|
from walla.cli.catch import run_cmd
|
|
13
14
|
from walla.hunter.profile_store import load_profile
|
|
@@ -37,6 +38,11 @@ def register(app: typer.Typer) -> None:
|
|
|
37
38
|
floor: float | None = typer.Option(
|
|
38
39
|
None, "--floor", help="Lowest EUR desk may accept from a buyer"
|
|
39
40
|
),
|
|
41
|
+
suggest: bool = typer.Option(
|
|
42
|
+
False,
|
|
43
|
+
"--suggest",
|
|
44
|
+
help="Prefill category via Wallapop steps (needs --title)",
|
|
45
|
+
),
|
|
40
46
|
yes: bool = typer.Option(False, "--yes", help="Publish for real"),
|
|
41
47
|
json: bool = typer.Option(False, "--json"),
|
|
42
48
|
) -> None:
|
|
@@ -47,17 +53,35 @@ def register(app: typer.Typer) -> None:
|
|
|
47
53
|
for p in paths:
|
|
48
54
|
if not p.is_file():
|
|
49
55
|
raise ValueError(f"photo not found: {p}")
|
|
56
|
+
title_use = title
|
|
50
57
|
leaf = category
|
|
51
58
|
root_id = root
|
|
52
59
|
picked = None
|
|
60
|
+
suggested = None
|
|
61
|
+
auto = bool(title) and not category and not yes
|
|
62
|
+
if suggest or auto:
|
|
63
|
+
if not title:
|
|
64
|
+
raise ValueError("--suggest needs --title")
|
|
65
|
+
suggested = suggest_from_photos(
|
|
66
|
+
paths, title=title, root_category_id=root
|
|
67
|
+
)
|
|
68
|
+
if suggested.get("category_leaf_id"):
|
|
69
|
+
leaf = str(suggested["category_leaf_id"])
|
|
70
|
+
root_id = str(suggested.get("root_category_id") or "")
|
|
53
71
|
if category:
|
|
54
72
|
picked = pick_sell_category(
|
|
55
73
|
get_categories(), category=category, root=root
|
|
56
74
|
)
|
|
57
75
|
leaf = picked.leaf_id
|
|
58
76
|
root_id = picked.root_id
|
|
77
|
+
elif leaf and str(leaf).isdigit():
|
|
78
|
+
picked = pick_sell_category(
|
|
79
|
+
get_categories(), category=str(leaf), root=root_id or root
|
|
80
|
+
)
|
|
81
|
+
leaf = picked.leaf_id
|
|
82
|
+
root_id = picked.root_id
|
|
59
83
|
fields: dict[str, Any] = {
|
|
60
|
-
"title":
|
|
84
|
+
"title": title_use,
|
|
61
85
|
"description": description,
|
|
62
86
|
"eur": eur,
|
|
63
87
|
"category_leaf_id": leaf,
|
|
@@ -82,7 +106,7 @@ def register(app: typer.Typer) -> None:
|
|
|
82
106
|
},
|
|
83
107
|
"hint": (
|
|
84
108
|
"Fill missing fields, then walla sell <photos> … --yes. "
|
|
85
|
-
"
|
|
109
|
+
"walla sell … --title … --suggest prefills category from Wallapop."
|
|
86
110
|
),
|
|
87
111
|
}
|
|
88
112
|
if picked is not None:
|
|
@@ -91,6 +115,8 @@ def register(app: typer.Typer) -> None:
|
|
|
91
115
|
"root_id": picked.root_id,
|
|
92
116
|
"path": picked.path,
|
|
93
117
|
}
|
|
118
|
+
if suggested is not None:
|
|
119
|
+
draft["suggested"] = suggested
|
|
94
120
|
if not yes:
|
|
95
121
|
return {"status": "draft", **draft}
|
|
96
122
|
if missing:
|
|
@@ -100,15 +126,15 @@ def register(app: typer.Typer) -> None:
|
|
|
100
126
|
)
|
|
101
127
|
if profile.lat is None or profile.lon is None:
|
|
102
128
|
raise ValueError("Set location first: walla setup --lat … --lon …")
|
|
103
|
-
assert
|
|
129
|
+
assert title_use and description and eur is not None and leaf and root_id
|
|
104
130
|
lowest = min(floor, eur) if floor is not None else eur
|
|
105
131
|
created = publish_listing(
|
|
106
132
|
paths,
|
|
107
|
-
title=
|
|
133
|
+
title=title_use,
|
|
108
134
|
description=description,
|
|
109
135
|
price_eur=eur,
|
|
110
|
-
category_leaf_id=leaf,
|
|
111
|
-
root_category_id=root_id,
|
|
136
|
+
category_leaf_id=str(leaf),
|
|
137
|
+
root_category_id=str(root_id),
|
|
112
138
|
lat=profile.lat,
|
|
113
139
|
lon=profile.lon,
|
|
114
140
|
condition=condition,
|
|
@@ -118,13 +144,13 @@ def register(app: typer.Typer) -> None:
|
|
|
118
144
|
remember(
|
|
119
145
|
Pursuit(
|
|
120
146
|
item_id=str(created["id"]),
|
|
121
|
-
title=
|
|
147
|
+
title=title_use,
|
|
122
148
|
url=str(created.get("url") or ""),
|
|
123
149
|
side="sell",
|
|
124
150
|
ask_eur=eur,
|
|
125
151
|
offer_eur=eur,
|
|
126
152
|
walk_away_eur=lowest,
|
|
127
|
-
query=
|
|
153
|
+
query=title_use,
|
|
128
154
|
status="waiting",
|
|
129
155
|
why="Listing is live. Desk talks to buyers. You accept in the app.",
|
|
130
156
|
)
|
|
@@ -123,6 +123,7 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
123
123
|
"talk": [
|
|
124
124
|
'walla pursue "<query>" --json',
|
|
125
125
|
"walla desk --json",
|
|
126
|
+
"walla desk --watch --seconds 45 --rounds 12 --json",
|
|
126
127
|
"walla negotiate <id> --json",
|
|
127
128
|
"walla inbox --json",
|
|
128
129
|
],
|
|
@@ -132,6 +133,7 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
132
133
|
],
|
|
133
134
|
"sell": [
|
|
134
135
|
"walla categories --find <name> --json",
|
|
136
|
+
"walla sell <photos> --title … --suggest --json",
|
|
135
137
|
"walla sell <photo.jpg>… --json # returns questions",
|
|
136
138
|
"walla sell <photos> --title … --desc … --eur N "
|
|
137
139
|
"--category <leaf-or-name> [--root <root>] --yes",
|
|
@@ -142,8 +144,10 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
142
144
|
"Always present Wallapop shortlists ranked best-first with markdown item links.",
|
|
143
145
|
"search / item / inbox / negotiate are safe reads or drafts.",
|
|
144
146
|
"pursue and desk send negotiation messages. Do not ask the human to send.",
|
|
147
|
+
"desk --watch polls until stack.best converges; still never pay.",
|
|
145
148
|
"Bare say, offer, and sell still need --yes. Never pay or accept in walla.",
|
|
146
149
|
"Sell: drop photos, ask data.questions, then publish with --yes only.",
|
|
150
|
+
"Sell --suggest prefills category from Wallapop steps when title is set.",
|
|
147
151
|
"If only asked to look, do not send.",
|
|
148
152
|
"Ambiguous match or multiple GRABs: ask once with candidates.",
|
|
149
153
|
"Default search is all of Spain. Shipping is enough; do not fence on profile.km.",
|
|
@@ -167,8 +167,16 @@ def build_server() -> Any:
|
|
|
167
167
|
return _wrap(lambda: run_pursue(keywords, wave=wave))
|
|
168
168
|
|
|
169
169
|
@mcp.tool()
|
|
170
|
-
def desk(
|
|
171
|
-
|
|
170
|
+
def desk(
|
|
171
|
+
watch: bool = False,
|
|
172
|
+
seconds: float = 45.0,
|
|
173
|
+
rounds: int = 12,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Inbox pass (or --watch loop) that answers threads until a price converges."""
|
|
176
|
+
from walla.account.desk_watch import run_desk_watch
|
|
177
|
+
|
|
178
|
+
if watch:
|
|
179
|
+
return _wrap(lambda: run_desk_watch(seconds=seconds, rounds=rounds))
|
|
172
180
|
return _wrap(run_desk)
|
|
173
181
|
|
|
174
182
|
@mcp.tool()
|
|
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
|
|
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
|