walla-cli 0.1.9__tar.gz → 0.1.10__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.10}/PKG-INFO +4 -3
- {walla_cli-0.1.9 → walla_cli-0.1.10}/README.md +3 -2
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/WIRE.md +4 -1
- walla_cli-0.1.10/docs/assets/walla-demo-preview.gif +0 -0
- walla_cli-0.1.10/fixtures/categories.json +59 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/pyproject.toml +1 -1
- {walla_cli-0.1.9 → walla_cli-0.1.10}/skills/walla-sell/SKILL.md +5 -4
- walla_cli-0.1.10/tests/test_categories_find.py +58 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_parsers.py +2 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/__init__.py +1 -1
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/sell_wire.py +5 -2
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_look.py +27 -1
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_sell.py +36 -10
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/instruct.py +2 -1
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/mcp/server.py +26 -4
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/profile.py +1 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/search/__init__.py +12 -0
- walla_cli-0.1.10/walla/search/category_find.py +98 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/search/parse.py +17 -13
- walla_cli-0.1.10/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.10}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/.gitignore +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/AGENTS.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/LICENSE +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/assets/walla-demo-poster.jpg +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/assets/walla-demo.mp4 +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/code_quality.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/mcp.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/objective_function.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/offer_buyer_request.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/sell_item_request.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/wire_contracts.json +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/scripts/check_code_quality.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/scripts/dogfood_run.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/skills/walla/SKILL.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/skills/walla-buy/SKILL.md +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_account.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_account_more.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_catch.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_chat_open.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_core.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_desk.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_edges.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_export.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_live.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_negotiate.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_polite.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_regressions.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_sell.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_session_store.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_shortlist.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_verdict.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_wire_contracts.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/uv.lock +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/actions.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/chat.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/cookies.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/desk.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/inbox.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/login.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/offer_wire.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/prompt.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/sell.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/banner.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_account.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_desk.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_ops.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/console.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/main.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/errors.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/path.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/client.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/headers.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/polite.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/converge.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/export.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/export_pdf.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/negotiate.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/profile_store.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/pursuits.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/shortlist.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/stack.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/verdict.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/account.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/listing.py +0 -0
- {walla_cli-0.1.9 → walla_cli-0.1.10}/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.10
|
|
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,10 @@ 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.
|
|
33
36
|
|
|
34
37
|
## Auth
|
|
35
38
|
|
|
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
|
+
}
|
|
@@ -13,6 +13,7 @@ license: MIT
|
|
|
13
13
|
Do not ask the human to type buyer replies. Desk sends those.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
+
walla categories --find escritorio --json
|
|
16
17
|
walla sell ./shot1.jpg ./shot2.jpg --json
|
|
17
18
|
```
|
|
18
19
|
|
|
@@ -21,13 +22,13 @@ Ask every question in `data.questions`, then publish once:
|
|
|
21
22
|
```bash
|
|
22
23
|
walla sell ./shot1.jpg ./shot2.jpg \
|
|
23
24
|
--title "…" --desc "…" --eur 40 \
|
|
24
|
-
--category
|
|
25
|
+
--category escritorio --condition good \
|
|
25
26
|
--floor 35 --yes --json
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
`--
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
`--category` accepts a leaf id **or** name tokens (`escritorio`, `hogar/muebles`).
|
|
30
|
+
`--root` is optional (derived from the leaf). `--floor` is the lowest EUR desk may
|
|
31
|
+
accept. Omit it to hold the asking price.
|
|
31
32
|
Then:
|
|
32
33
|
|
|
33
34
|
```bash
|
|
@@ -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")
|
|
@@ -51,11 +51,14 @@ SELL_QUESTIONS = (
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"id": "category_leaf_id",
|
|
54
|
-
"ask":
|
|
54
|
+
"ask": (
|
|
55
|
+
"Category leaf id OR name tokens (e.g. 24208 or escritorio). "
|
|
56
|
+
"Root is auto-derived. Preview: walla categories --find …"
|
|
57
|
+
),
|
|
55
58
|
},
|
|
56
59
|
{
|
|
57
60
|
"id": "root_category_id",
|
|
58
|
-
"ask": "Root category id (
|
|
61
|
+
"ask": "Root category id (optional if leaf/name resolves).",
|
|
59
62
|
},
|
|
60
63
|
{
|
|
61
64
|
"id": "shipping",
|
|
@@ -21,6 +21,7 @@ from walla.hunter.watches import (
|
|
|
21
21
|
save_watches,
|
|
22
22
|
)
|
|
23
23
|
from walla.search.api import get_categories, get_item, search_listings
|
|
24
|
+
from walla.search.category_find import find_categories
|
|
24
25
|
|
|
25
26
|
__all__ = ("register",)
|
|
26
27
|
|
|
@@ -144,9 +145,34 @@ def register(app: typer.Typer) -> None:
|
|
|
144
145
|
run_cmd(_run, as_json=json)
|
|
145
146
|
|
|
146
147
|
@app.command("categories")
|
|
147
|
-
def categories_cmd(
|
|
148
|
+
def categories_cmd(
|
|
149
|
+
find: str | None = typer.Option(
|
|
150
|
+
None, "--find", help="Name tokens, e.g. escritorio or hogar/muebles"
|
|
151
|
+
),
|
|
152
|
+
json: bool = typer.Option(False, "--json"),
|
|
153
|
+
) -> None:
|
|
148
154
|
def _run() -> dict[str, Any]:
|
|
149
155
|
cats = get_categories()
|
|
156
|
+
if find:
|
|
157
|
+
hits = find_categories(cats, find)
|
|
158
|
+
return {
|
|
159
|
+
"query": find,
|
|
160
|
+
"count": len(hits),
|
|
161
|
+
"matches": [
|
|
162
|
+
{
|
|
163
|
+
"leaf_id": h.leaf_id,
|
|
164
|
+
"root_id": h.root_id,
|
|
165
|
+
"path": h.path,
|
|
166
|
+
"name": h.name,
|
|
167
|
+
"is_leaf": h.is_leaf,
|
|
168
|
+
}
|
|
169
|
+
for h in hits[:40]
|
|
170
|
+
],
|
|
171
|
+
"hint": (
|
|
172
|
+
"Use leaf_id as --category on walla sell; "
|
|
173
|
+
"root_id is optional (auto-derived)."
|
|
174
|
+
),
|
|
175
|
+
}
|
|
150
176
|
return {"categories": [c.model_dump(mode="json") for c in cats]}
|
|
151
177
|
|
|
152
178
|
run_cmd(_run, as_json=json)
|
|
@@ -12,6 +12,8 @@ from walla.account.sell_wire import CONDITIONS, SELL_QUESTIONS, missing_sell_fie
|
|
|
12
12
|
from walla.cli.catch import run_cmd
|
|
13
13
|
from walla.hunter.profile_store import load_profile
|
|
14
14
|
from walla.hunter.pursuits import Pursuit, drop_pursuit, remember
|
|
15
|
+
from walla.search.api import get_categories
|
|
16
|
+
from walla.search.pick_category import pick_sell_category
|
|
15
17
|
|
|
16
18
|
__all__ = ("register",)
|
|
17
19
|
|
|
@@ -23,8 +25,12 @@ def register(app: typer.Typer) -> None:
|
|
|
23
25
|
title: str | None = typer.Option(None, "--title"),
|
|
24
26
|
description: str | None = typer.Option(None, "--description", "--desc"),
|
|
25
27
|
eur: float | None = typer.Option(None, "--eur"),
|
|
26
|
-
category: str | None = typer.Option(
|
|
27
|
-
|
|
28
|
+
category: str | None = typer.Option(
|
|
29
|
+
None, "--category", help="Leaf id or name (escritorio)"
|
|
30
|
+
),
|
|
31
|
+
root: str | None = typer.Option(
|
|
32
|
+
None, "--root", help="Root id (optional; derived from leaf)"
|
|
33
|
+
),
|
|
28
34
|
condition: str = typer.Option("good", "--condition"),
|
|
29
35
|
ship: bool = typer.Option(False, "--ship"),
|
|
30
36
|
weight_kg: float | None = typer.Option(None, "--weight-kg"),
|
|
@@ -41,12 +47,21 @@ def register(app: typer.Typer) -> None:
|
|
|
41
47
|
for p in paths:
|
|
42
48
|
if not p.is_file():
|
|
43
49
|
raise ValueError(f"photo not found: {p}")
|
|
50
|
+
leaf = category
|
|
51
|
+
root_id = root
|
|
52
|
+
picked = None
|
|
53
|
+
if category:
|
|
54
|
+
picked = pick_sell_category(
|
|
55
|
+
get_categories(), category=category, root=root
|
|
56
|
+
)
|
|
57
|
+
leaf = picked.leaf_id
|
|
58
|
+
root_id = picked.root_id
|
|
44
59
|
fields: dict[str, Any] = {
|
|
45
60
|
"title": title,
|
|
46
61
|
"description": description,
|
|
47
62
|
"eur": eur,
|
|
48
|
-
"category_leaf_id":
|
|
49
|
-
"root_category_id":
|
|
63
|
+
"category_leaf_id": leaf,
|
|
64
|
+
"root_category_id": root_id,
|
|
50
65
|
"condition": condition,
|
|
51
66
|
"shipping": ship,
|
|
52
67
|
"weight_kg": weight_kg,
|
|
@@ -54,7 +69,7 @@ def register(app: typer.Typer) -> None:
|
|
|
54
69
|
missing = missing_sell_fields(fields)
|
|
55
70
|
profile = load_profile()
|
|
56
71
|
questions = [q for q in SELL_QUESTIONS if q["id"] in missing]
|
|
57
|
-
draft = {
|
|
72
|
+
draft: dict[str, Any] = {
|
|
58
73
|
"photos": [str(p) for p in paths],
|
|
59
74
|
"fields": fields,
|
|
60
75
|
"missing": missing,
|
|
@@ -67,9 +82,15 @@ def register(app: typer.Typer) -> None:
|
|
|
67
82
|
},
|
|
68
83
|
"hint": (
|
|
69
84
|
"Fill missing fields, then walla sell <photos> … --yes. "
|
|
70
|
-
"
|
|
85
|
+
"Category: leaf id or name (walla categories --find …)."
|
|
71
86
|
),
|
|
72
87
|
}
|
|
88
|
+
if picked is not None:
|
|
89
|
+
draft["category"] = {
|
|
90
|
+
"leaf_id": picked.leaf_id,
|
|
91
|
+
"root_id": picked.root_id,
|
|
92
|
+
"path": picked.path,
|
|
93
|
+
}
|
|
73
94
|
if not yes:
|
|
74
95
|
return {"status": "draft", **draft}
|
|
75
96
|
if missing:
|
|
@@ -79,15 +100,15 @@ def register(app: typer.Typer) -> None:
|
|
|
79
100
|
)
|
|
80
101
|
if profile.lat is None or profile.lon is None:
|
|
81
102
|
raise ValueError("Set location first: walla setup --lat … --lon …")
|
|
82
|
-
assert title and description and eur is not None and
|
|
103
|
+
assert title and description and eur is not None and leaf and root_id
|
|
83
104
|
lowest = min(floor, eur) if floor is not None else eur
|
|
84
105
|
created = publish_listing(
|
|
85
106
|
paths,
|
|
86
107
|
title=title,
|
|
87
108
|
description=description,
|
|
88
109
|
price_eur=eur,
|
|
89
|
-
category_leaf_id=
|
|
90
|
-
root_category_id=
|
|
110
|
+
category_leaf_id=leaf,
|
|
111
|
+
root_category_id=root_id,
|
|
91
112
|
lat=profile.lat,
|
|
92
113
|
lon=profile.lon,
|
|
93
114
|
condition=condition,
|
|
@@ -108,7 +129,12 @@ def register(app: typer.Typer) -> None:
|
|
|
108
129
|
why="Listing is live. Desk talks to buyers. You accept in the app.",
|
|
109
130
|
)
|
|
110
131
|
)
|
|
111
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
"status": "published",
|
|
134
|
+
"floor_eur": lowest,
|
|
135
|
+
"category": draft.get("category"),
|
|
136
|
+
**created,
|
|
137
|
+
}
|
|
112
138
|
|
|
113
139
|
run_cmd(_run, as_json=json)
|
|
114
140
|
|
|
@@ -131,9 +131,10 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
131
131
|
"walla offer <id> --eur <n> --yes",
|
|
132
132
|
],
|
|
133
133
|
"sell": [
|
|
134
|
+
"walla categories --find <name> --json",
|
|
134
135
|
"walla sell <photo.jpg>… --json # returns questions",
|
|
135
136
|
"walla sell <photos> --title … --desc … --eur N "
|
|
136
|
-
"--category <leaf> --root <root> --yes",
|
|
137
|
+
"--category <leaf-or-name> [--root <root>] --yes",
|
|
137
138
|
"walla unsell <item_id> --yes",
|
|
138
139
|
],
|
|
139
140
|
},
|
|
@@ -83,10 +83,32 @@ def build_server() -> Any:
|
|
|
83
83
|
return _wrap(_run)
|
|
84
84
|
|
|
85
85
|
@mcp.tool()
|
|
86
|
-
def categories() -> dict[str, Any]:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
86
|
+
def categories(find: str | None = None) -> dict[str, Any]:
|
|
87
|
+
"""List the category tree, or find leaf/root ids by name tokens."""
|
|
88
|
+
|
|
89
|
+
def _run() -> dict[str, Any]:
|
|
90
|
+
from walla.search.category_find import find_categories
|
|
91
|
+
|
|
92
|
+
cats = get_categories()
|
|
93
|
+
if find:
|
|
94
|
+
hits = find_categories(cats, find)
|
|
95
|
+
return {
|
|
96
|
+
"query": find,
|
|
97
|
+
"count": len(hits),
|
|
98
|
+
"matches": [
|
|
99
|
+
{
|
|
100
|
+
"leaf_id": h.leaf_id,
|
|
101
|
+
"root_id": h.root_id,
|
|
102
|
+
"path": h.path,
|
|
103
|
+
"name": h.name,
|
|
104
|
+
"is_leaf": h.is_leaf,
|
|
105
|
+
}
|
|
106
|
+
for h in hits[:40]
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
return {"categories": [c.model_dump(mode="json") for c in cats]}
|
|
110
|
+
|
|
111
|
+
return _wrap(_run)
|
|
90
112
|
|
|
91
113
|
@mcp.tool()
|
|
92
114
|
def login(cookie: str) -> dict[str, Any]:
|
|
@@ -3,13 +3,25 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from walla.search.api import get_categories, get_item, search_listings
|
|
6
|
+
from walla.search.category_find import (
|
|
7
|
+
CategoryHit,
|
|
8
|
+
find_categories,
|
|
9
|
+
flatten_categories,
|
|
10
|
+
resolve_root_id,
|
|
11
|
+
)
|
|
6
12
|
from walla.search.parse import parse_categories, parse_item, parse_search
|
|
13
|
+
from walla.search.pick_category import pick_sell_category
|
|
7
14
|
|
|
8
15
|
__all__ = (
|
|
16
|
+
"CategoryHit",
|
|
17
|
+
"find_categories",
|
|
18
|
+
"flatten_categories",
|
|
9
19
|
"get_categories",
|
|
10
20
|
"get_item",
|
|
11
21
|
"parse_categories",
|
|
12
22
|
"parse_item",
|
|
13
23
|
"parse_search",
|
|
24
|
+
"pick_sell_category",
|
|
25
|
+
"resolve_root_id",
|
|
14
26
|
"search_listings",
|
|
15
27
|
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Find leaf/root category ids by name tokens (pure)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from walla.models.profile import Category
|
|
8
|
+
|
|
9
|
+
__all__ = (
|
|
10
|
+
"CategoryHit",
|
|
11
|
+
"find_categories",
|
|
12
|
+
"flatten_categories",
|
|
13
|
+
"resolve_root_id",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CategoryHit:
|
|
19
|
+
leaf_id: str
|
|
20
|
+
root_id: str
|
|
21
|
+
path: str
|
|
22
|
+
name: str
|
|
23
|
+
is_leaf: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def flatten_categories(roots: list[Category]) -> list[CategoryHit]:
|
|
27
|
+
"""Walk the tree; every node becomes a hit with its root id."""
|
|
28
|
+
out: list[CategoryHit] = []
|
|
29
|
+
|
|
30
|
+
def walk(nodes: list[Category], root_id: int, parts: tuple[str, ...]) -> None:
|
|
31
|
+
for node in nodes:
|
|
32
|
+
path_parts = parts + (node.name,)
|
|
33
|
+
kids = list(node.subcategories)
|
|
34
|
+
out.append(
|
|
35
|
+
CategoryHit(
|
|
36
|
+
leaf_id=str(node.id),
|
|
37
|
+
root_id=str(root_id),
|
|
38
|
+
path=" / ".join(path_parts),
|
|
39
|
+
name=node.name,
|
|
40
|
+
is_leaf=not kids,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
if kids:
|
|
44
|
+
walk(kids, root_id, path_parts)
|
|
45
|
+
|
|
46
|
+
for root in roots:
|
|
47
|
+
walk([root], root.id, ())
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_categories(roots: list[Category], query: str) -> list[CategoryHit]:
|
|
52
|
+
"""Rank hits whose path contains every query token (space or /)."""
|
|
53
|
+
tokens = [t for t in query.lower().replace("/", " ").split() if t]
|
|
54
|
+
if not tokens:
|
|
55
|
+
raise ValueError("category find query is empty")
|
|
56
|
+
scored: list[tuple[int, CategoryHit]] = []
|
|
57
|
+
for hit in flatten_categories(roots):
|
|
58
|
+
blob = hit.path.lower()
|
|
59
|
+
if not all(tok in blob for tok in tokens):
|
|
60
|
+
continue
|
|
61
|
+
score = 0
|
|
62
|
+
if hit.is_leaf:
|
|
63
|
+
score += 20
|
|
64
|
+
name_l = hit.name.lower()
|
|
65
|
+
joined = " ".join(tokens)
|
|
66
|
+
if name_l == joined or _stem(name_l) == _stem(tokens[-1]):
|
|
67
|
+
score += 50
|
|
68
|
+
elif tokens[-1] == name_l or _stem(tokens[-1]) == _stem(name_l):
|
|
69
|
+
score += 40
|
|
70
|
+
elif name_l.startswith(tokens[-1]) or tokens[-1].startswith(_stem(name_l)):
|
|
71
|
+
score += 25
|
|
72
|
+
score += sum(6 for tok in tokens if tok in name_l)
|
|
73
|
+
# Prefer the named leaf over a shallow cousin that only contains the token.
|
|
74
|
+
if any(_stem(part) == _stem(tokens[-1]) for part in blob.split(" / ")):
|
|
75
|
+
score += 15
|
|
76
|
+
scored.append((score, hit))
|
|
77
|
+
scored.sort(key=lambda row: (-row[0], len(row[1].path), row[1].path.lower()))
|
|
78
|
+
return [hit for _, hit in scored]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _stem(word: str) -> str:
|
|
82
|
+
w = word.lower().strip()
|
|
83
|
+
if w.endswith("es") and len(w) > 4:
|
|
84
|
+
return w[:-2]
|
|
85
|
+
if w.endswith("s") and len(w) > 3:
|
|
86
|
+
return w[:-1]
|
|
87
|
+
return w
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_root_id(roots: list[Category], leaf_id: str) -> str:
|
|
91
|
+
"""Return the root category id that owns leaf_id."""
|
|
92
|
+
needle = str(leaf_id).strip()
|
|
93
|
+
if not needle:
|
|
94
|
+
raise ValueError("leaf category id is empty")
|
|
95
|
+
for hit in flatten_categories(roots):
|
|
96
|
+
if hit.leaf_id == needle:
|
|
97
|
+
return hit.root_id
|
|
98
|
+
raise ValueError(f"unknown category leaf id: {needle}")
|
|
@@ -135,16 +135,20 @@ def parse_categories(payload: dict[str, Any]) -> list[Category]:
|
|
|
135
135
|
cats = payload.get("categories") or payload.get("data") or []
|
|
136
136
|
if not isinstance(cats, list):
|
|
137
137
|
raise WallaParseError("categories missing")
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
|
|
138
|
+
return [_parse_category_node(c) for c in cats if isinstance(c, dict)]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_category_node(raw: dict[str, Any]) -> Category:
|
|
142
|
+
kids_raw = raw.get("subcategories") or []
|
|
143
|
+
kids: list[Category] = []
|
|
144
|
+
if isinstance(kids_raw, list):
|
|
145
|
+
kids = [_parse_category_node(c) for c in kids_raw if isinstance(c, dict)]
|
|
146
|
+
return Category(
|
|
147
|
+
id=int(raw["id"]),
|
|
148
|
+
name=str(raw.get("name") or ""),
|
|
149
|
+
icon=raw.get("icon") if isinstance(raw.get("icon"), str) else None,
|
|
150
|
+
vertical_id=(
|
|
151
|
+
str(raw["vertical_id"]) if raw.get("vertical_id") is not None else None
|
|
152
|
+
),
|
|
153
|
+
subcategories=kids,
|
|
154
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Resolve sell category leaf/root from an id or name query."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from walla.models.profile import Category
|
|
6
|
+
from walla.search.category_find import CategoryHit, find_categories, flatten_categories
|
|
7
|
+
|
|
8
|
+
__all__ = ("pick_sell_category",)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pick_sell_category(
|
|
12
|
+
roots: list[Category],
|
|
13
|
+
*,
|
|
14
|
+
category: str,
|
|
15
|
+
root: str | None = None,
|
|
16
|
+
) -> CategoryHit:
|
|
17
|
+
"""Accept a leaf id or a find query; derive root when omitted."""
|
|
18
|
+
text = category.strip()
|
|
19
|
+
if not text:
|
|
20
|
+
raise ValueError("category is empty")
|
|
21
|
+
forced = str(root).strip() if root else None
|
|
22
|
+
if text.isdigit():
|
|
23
|
+
hit = next((h for h in flatten_categories(roots) if h.leaf_id == text), None)
|
|
24
|
+
if hit is None:
|
|
25
|
+
raise ValueError(f"unknown category leaf id: {text}")
|
|
26
|
+
return CategoryHit(
|
|
27
|
+
leaf_id=hit.leaf_id,
|
|
28
|
+
root_id=forced or hit.root_id,
|
|
29
|
+
path=hit.path,
|
|
30
|
+
name=hit.name,
|
|
31
|
+
is_leaf=hit.is_leaf,
|
|
32
|
+
)
|
|
33
|
+
hits = find_categories(roots, text)
|
|
34
|
+
if not hits:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"no category matches {text!r}; try: walla categories --find {text!r}"
|
|
37
|
+
)
|
|
38
|
+
best = hits[0]
|
|
39
|
+
if forced:
|
|
40
|
+
return CategoryHit(
|
|
41
|
+
leaf_id=best.leaf_id,
|
|
42
|
+
root_id=forced,
|
|
43
|
+
path=best.path,
|
|
44
|
+
name=best.name,
|
|
45
|
+
is_leaf=best.is_leaf,
|
|
46
|
+
)
|
|
47
|
+
return best
|
|
@@ -1,49 +0,0 @@
|
|
|
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": 14000,
|
|
14
|
-
"name": "Motorbike",
|
|
15
|
-
"path": "",
|
|
16
|
-
"vertical_id": "consumer_goods",
|
|
17
|
-
"icon": "motorbike",
|
|
18
|
-
"subcategories": [],
|
|
19
|
-
"category_leaf_selection_mandatory": false
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
"id": 12800,
|
|
23
|
-
"name": "Motors & accessories",
|
|
24
|
-
"path": "",
|
|
25
|
-
"vertical_id": "consumer_goods",
|
|
26
|
-
"icon": "helmet",
|
|
27
|
-
"subcategories": [],
|
|
28
|
-
"category_leaf_selection_mandatory": false
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"id": 12465,
|
|
32
|
-
"name": "Fashion & accessories",
|
|
33
|
-
"path": "",
|
|
34
|
-
"vertical_id": "consumer_goods",
|
|
35
|
-
"icon": "t-shirt",
|
|
36
|
-
"subcategories": [],
|
|
37
|
-
"category_leaf_selection_mandatory": true
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"id": 200,
|
|
41
|
-
"name": "Real Estate",
|
|
42
|
-
"path": "",
|
|
43
|
-
"vertical_id": "real_estate",
|
|
44
|
-
"icon": "house",
|
|
45
|
-
"subcategories": [],
|
|
46
|
-
"category_leaf_selection_mandatory": false
|
|
47
|
-
}
|
|
48
|
-
]
|
|
49
|
-
}
|
|
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
|