walla-cli 0.1.9__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.9 → walla_cli-0.1.11}/PKG-INFO +4 -3
- {walla_cli-0.1.9 → walla_cli-0.1.11}/README.md +3 -2
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/WIRE.md +9 -1
- walla_cli-0.1.11/docs/assets/walla-demo-preview.gif +0 -0
- walla_cli-0.1.11/fixtures/categories.json +59 -0
- walla_cli-0.1.11/fixtures/sell_suggest_draft.json +23 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/pyproject.toml +1 -1
- {walla_cli-0.1.9 → walla_cli-0.1.11}/skills/walla-sell/SKILL.md +10 -8
- walla_cli-0.1.11/tests/test_categories_find.py +58 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_live.py +2 -1
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_parsers.py +2 -0
- {walla_cli-0.1.9 → 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.9 → walla_cli-0.1.11}/tests/test_wire_contracts.py +3 -2
- {walla_cli-0.1.9 → 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.9 → 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.9 → walla_cli-0.1.11}/walla/account/sell_wire.py +5 -2
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_desk.py +12 -2
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_look.py +27 -1
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_sell.py +66 -14
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/instruct.py +6 -1
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/mcp/server.py +36 -6
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/profile.py +1 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/search/__init__.py +12 -0
- walla_cli-0.1.11/walla/search/category_find.py +98 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/search/parse.py +17 -13
- walla_cli-0.1.11/walla/search/pick_category.py +47 -0
- walla_cli-0.1.9/fixtures/categories.json +0 -49
- {walla_cli-0.1.9 → walla_cli-0.1.11}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/.gitignore +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/AGENTS.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/LICENSE +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/assets/walla-demo-poster.jpg +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/assets/walla-demo.mp4 +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/code_quality.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/mcp.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/objective_function.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/offer_buyer_request.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/sell_item_request.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/fixtures/wire_contracts.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/scripts/check_code_quality.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/scripts/dogfood_run.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/skills/walla/SKILL.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/skills/walla-buy/SKILL.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_account.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_account_more.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_catch.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_chat_open.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_core.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_desk.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_edges.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_export.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_negotiate.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_polite.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_regressions.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_session_store.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_shortlist.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/tests/test_verdict.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/uv.lock +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/actions.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/chat.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/cookies.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/desk.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/inbox.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/login.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/offer_wire.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/prompt.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/banner.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_account.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/cmd_ops.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/console.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/cli/main.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/errors.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/core/path.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/client.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/headers.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/http/polite.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/converge.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/export.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/export_pdf.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/negotiate.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/profile_store.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/pursuits.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/shortlist.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/stack.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/verdict.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/account.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/models/listing.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.11}/walla/search/api.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
|
|
@@ -55,9 +55,10 @@ Description-Content-Type: text/markdown
|
|
|
55
55
|
Dr. Berend Gort · www.berendgort.dev
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-

|
|
58
|
+
[](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
▶ [Play full demo with sound](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
|
|
61
|
+
· Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
|
|
61
62
|
([Mixkit Stock Music Free License](https://mixkit.co/license/#musicFree)).
|
|
62
63
|
|
|
63
64
|
**walla** turns Claude, GPT, or Cursor into a Wallapop helper that actually
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
Dr. Berend Gort · www.berendgort.dev
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-

|
|
14
|
+
[](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
▶ [Play full demo with sound](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
|
|
17
|
+
· Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
|
|
17
18
|
([Mixkit Stock Music Free License](https://mixkit.co/license/#musicFree)).
|
|
18
19
|
|
|
19
20
|
**walla** turns Claude, GPT, or Cursor into a Wallapop helper that actually
|
|
@@ -29,7 +29,15 @@ Public URL: `https://es.wallapop.com/item/<web_slug>`.
|
|
|
29
29
|
|
|
30
30
|
## Categories
|
|
31
31
|
|
|
32
|
-
`GET https://api.wallapop.com/api/v3/categories` —
|
|
32
|
+
`GET https://api.wallapop.com/api/v3/categories` — nested tree
|
|
33
|
+
(`subcategories[]`). CLI: `walla categories --find escritorio --json` returns
|
|
34
|
+
`leaf_id` + `root_id`. Sell accepts `--category <leaf-or-name>`; `--root` is
|
|
35
|
+
optional.
|
|
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.
|
|
33
41
|
|
|
34
42
|
## Auth
|
|
35
43
|
|
|
Binary file
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"categories": [
|
|
3
|
+
{
|
|
4
|
+
"id": 100,
|
|
5
|
+
"name": "Cars",
|
|
6
|
+
"path": "",
|
|
7
|
+
"vertical_id": "cars",
|
|
8
|
+
"icon": "car",
|
|
9
|
+
"subcategories": [],
|
|
10
|
+
"category_leaf_selection_mandatory": false
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": 12467,
|
|
14
|
+
"name": "Hogar y jardín",
|
|
15
|
+
"path": "",
|
|
16
|
+
"vertical_id": "consumer_goods",
|
|
17
|
+
"icon": "home",
|
|
18
|
+
"subcategories": [
|
|
19
|
+
{
|
|
20
|
+
"id": 10125,
|
|
21
|
+
"name": "Muebles y organización",
|
|
22
|
+
"parent_id": 12467,
|
|
23
|
+
"subcategories": [
|
|
24
|
+
{
|
|
25
|
+
"id": 24207,
|
|
26
|
+
"name": "Mesas y mesitas",
|
|
27
|
+
"parent_id": 10125,
|
|
28
|
+
"subcategories": [
|
|
29
|
+
{
|
|
30
|
+
"id": 24208,
|
|
31
|
+
"name": "Escritorios",
|
|
32
|
+
"parent_id": 24207,
|
|
33
|
+
"subcategories": []
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"category_leaf_selection_mandatory": false
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": 12579,
|
|
44
|
+
"name": "Deporte y ocio",
|
|
45
|
+
"path": "",
|
|
46
|
+
"vertical_id": "consumer_goods",
|
|
47
|
+
"icon": "ball",
|
|
48
|
+
"subcategories": [
|
|
49
|
+
{
|
|
50
|
+
"id": 10105,
|
|
51
|
+
"name": "Surf y bodyboard",
|
|
52
|
+
"parent_id": 12579,
|
|
53
|
+
"subcategories": []
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"category_leaf_selection_mandatory": false
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -13,29 +13,31 @@ license: MIT
|
|
|
13
13
|
Do not ask the human to type buyer replies. Desk sends those.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
walla
|
|
16
|
+
walla categories --find escritorio --json
|
|
17
|
+
walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
|
|
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:
|
|
20
22
|
|
|
21
23
|
```bash
|
|
22
24
|
walla sell ./shot1.jpg ./shot2.jpg \
|
|
23
25
|
--title "…" --desc "…" --eur 40 \
|
|
24
|
-
--category
|
|
26
|
+
--category escritorio --condition good \
|
|
25
27
|
--floor 35 --yes --json
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
`--
|
|
29
|
-
|
|
30
|
+
`--category` accepts a leaf id **or** name tokens. `--root` is optional.
|
|
31
|
+
`--floor` is the lowest EUR desk may accept.
|
|
30
32
|
|
|
31
33
|
Then:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
|
-
walla desk --json
|
|
36
|
+
walla desk --watch --seconds 45 --rounds 12 --json
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
Desk
|
|
38
|
-
|
|
39
|
+
Desk answers buyers until `stack.best` converges or rounds end. Show the title,
|
|
40
|
+
agreed EUR, `stack.why`, and the item link.
|
|
39
41
|
|
|
40
42
|
The human accepts the sale in the Wallapop app. `walla unsell <id> --yes` deletes
|
|
41
43
|
a listing. Never mark the item sold from walla.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Category tree find / root resolve."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from walla.search.category_find import find_categories, flatten_categories, resolve_root_id
|
|
11
|
+
from walla.search.parse import parse_categories
|
|
12
|
+
from walla.search.pick_category import pick_sell_category
|
|
13
|
+
|
|
14
|
+
FIX = Path(__file__).resolve().parents[1] / "fixtures"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _roots():
|
|
18
|
+
raw = json.loads((FIX / "categories.json").read_text(encoding="utf-8"))
|
|
19
|
+
return parse_categories(raw)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_parse_nested_categories() -> None:
|
|
23
|
+
roots = _roots()
|
|
24
|
+
hogar = next(c for c in roots if c.id == 12467)
|
|
25
|
+
assert hogar.subcategories
|
|
26
|
+
assert flatten_categories(roots)
|
|
27
|
+
assert resolve_root_id(roots, "24208") == "12467"
|
|
28
|
+
assert resolve_root_id(roots, "10105") == "12579"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_find_escritorio() -> None:
|
|
32
|
+
hits = find_categories(_roots(), "escritorio")
|
|
33
|
+
assert hits
|
|
34
|
+
assert hits[0].leaf_id == "24208"
|
|
35
|
+
assert hits[0].root_id == "12467"
|
|
36
|
+
assert hits[0].is_leaf
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_pick_by_name_and_id() -> None:
|
|
40
|
+
roots = _roots()
|
|
41
|
+
by_name = pick_sell_category(roots, category="escritorio")
|
|
42
|
+
assert by_name.leaf_id == "24208"
|
|
43
|
+
assert by_name.root_id == "12467"
|
|
44
|
+
by_id = pick_sell_category(roots, category="24208")
|
|
45
|
+
assert by_id.leaf_id == "24208"
|
|
46
|
+
assert by_id.root_id == "12467"
|
|
47
|
+
forced = pick_sell_category(roots, category="10105", root="12579")
|
|
48
|
+
assert forced.root_id == "12579"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_find_empty_and_unknown() -> None:
|
|
52
|
+
with pytest.raises(ValueError, match="empty"):
|
|
53
|
+
find_categories(_roots(), " ")
|
|
54
|
+
assert find_categories(_roots(), "zzzz-no-such") == []
|
|
55
|
+
with pytest.raises(ValueError, match="unknown"):
|
|
56
|
+
resolve_root_id(_roots(), "999999")
|
|
57
|
+
with pytest.raises(ValueError, match="no category matches"):
|
|
58
|
+
pick_sell_category(_roots(), category="zzzz-no-such")
|
|
@@ -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):
|