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.
Files changed (111) hide show
  1. {walla_cli-0.1.9 → walla_cli-0.1.10}/PKG-INFO +4 -3
  2. {walla_cli-0.1.9 → walla_cli-0.1.10}/README.md +3 -2
  3. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/WIRE.md +4 -1
  4. walla_cli-0.1.10/docs/assets/walla-demo-preview.gif +0 -0
  5. walla_cli-0.1.10/fixtures/categories.json +59 -0
  6. {walla_cli-0.1.9 → walla_cli-0.1.10}/pyproject.toml +1 -1
  7. {walla_cli-0.1.9 → walla_cli-0.1.10}/skills/walla-sell/SKILL.md +5 -4
  8. walla_cli-0.1.10/tests/test_categories_find.py +58 -0
  9. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_parsers.py +2 -0
  10. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/__init__.py +1 -1
  11. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/sell_wire.py +5 -2
  12. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_look.py +27 -1
  13. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_sell.py +36 -10
  14. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/instruct.py +2 -1
  15. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/mcp/server.py +26 -4
  16. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/profile.py +1 -0
  17. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/search/__init__.py +12 -0
  18. walla_cli-0.1.10/walla/search/category_find.py +98 -0
  19. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/search/parse.py +17 -13
  20. walla_cli-0.1.10/walla/search/pick_category.py +47 -0
  21. walla_cli-0.1.9/fixtures/categories.json +0 -49
  22. {walla_cli-0.1.9 → walla_cli-0.1.10}/.cursor/rules/quality.mdc +0 -0
  23. {walla_cli-0.1.9 → walla_cli-0.1.10}/.github/workflows/ci.yml +0 -0
  24. {walla_cli-0.1.9 → walla_cli-0.1.10}/.gitignore +0 -0
  25. {walla_cli-0.1.9 → walla_cli-0.1.10}/AGENTS.md +0 -0
  26. {walla_cli-0.1.9 → walla_cli-0.1.10}/LICENSE +0 -0
  27. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/assets/walla-demo-poster.jpg +0 -0
  28. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/assets/walla-demo.mp4 +0 -0
  29. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/assets/walla-github-banner.jpg +0 -0
  30. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/code_quality.md +0 -0
  31. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/data_engineering_standards.md +0 -0
  32. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/mcp.md +0 -0
  33. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/objective_function.md +0 -0
  34. {walla_cli-0.1.9 → walla_cli-0.1.10}/docs/rate_limit_analysis.md +0 -0
  35. {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/item_detail.json +0 -0
  36. {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/offer_buyer_request.json +0 -0
  37. {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/search_bicicleta.json +0 -0
  38. {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/sell_item_request.json +0 -0
  39. {walla_cli-0.1.9 → walla_cli-0.1.10}/fixtures/wire_contracts.json +0 -0
  40. {walla_cli-0.1.9 → walla_cli-0.1.10}/scripts/check_code_quality.py +0 -0
  41. {walla_cli-0.1.9 → walla_cli-0.1.10}/scripts/dogfood_run.py +0 -0
  42. {walla_cli-0.1.9 → walla_cli-0.1.10}/scripts/probe_rate.py +0 -0
  43. {walla_cli-0.1.9 → walla_cli-0.1.10}/skills/walla/SKILL.md +0 -0
  44. {walla_cli-0.1.9 → walla_cli-0.1.10}/skills/walla-buy/SKILL.md +0 -0
  45. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_account.py +0 -0
  46. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_account_more.py +0 -0
  47. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_catch.py +0 -0
  48. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_chat_open.py +0 -0
  49. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_cli_json.py +0 -0
  50. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_core.py +0 -0
  51. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_desk.py +0 -0
  52. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_edges.py +0 -0
  53. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_export.py +0 -0
  54. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_http_more.py +0 -0
  55. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_live.py +0 -0
  56. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_negotiate.py +0 -0
  57. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_polite.py +0 -0
  58. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_regressions.py +0 -0
  59. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_search_api.py +0 -0
  60. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_sell.py +0 -0
  61. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_session_store.py +0 -0
  62. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_shortlist.py +0 -0
  63. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_verdict.py +0 -0
  64. {walla_cli-0.1.9 → walla_cli-0.1.10}/tests/test_wire_contracts.py +0 -0
  65. {walla_cli-0.1.9 → walla_cli-0.1.10}/uv.lock +0 -0
  66. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/__init__.py +0 -0
  67. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/actions.py +0 -0
  68. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/chat.py +0 -0
  69. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/cookies.py +0 -0
  70. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/desk.py +0 -0
  71. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/inbox.py +0 -0
  72. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/login.py +0 -0
  73. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/offer_wire.py +0 -0
  74. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/prompt.py +0 -0
  75. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/sell.py +0 -0
  76. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/account/session_store.py +0 -0
  77. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/__init__.py +0 -0
  78. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/banner.py +0 -0
  79. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/catch.py +0 -0
  80. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_account.py +0 -0
  81. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_desk.py +0 -0
  82. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/cmd_ops.py +0 -0
  83. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/console.py +0 -0
  84. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/cli/main.py +0 -0
  85. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/__init__.py +0 -0
  86. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/envelope.py +0 -0
  87. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/errors.py +0 -0
  88. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/exceptions.py +0 -0
  89. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/human_fix.py +0 -0
  90. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/core/path.py +0 -0
  91. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/__init__.py +0 -0
  92. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/client.py +0 -0
  93. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/headers.py +0 -0
  94. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/http/polite.py +0 -0
  95. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/__init__.py +0 -0
  96. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/converge.py +0 -0
  97. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/export.py +0 -0
  98. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/export_pdf.py +0 -0
  99. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/negotiate.py +0 -0
  100. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/profile_store.py +0 -0
  101. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/pursuits.py +0 -0
  102. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/shortlist.py +0 -0
  103. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/stack.py +0 -0
  104. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/verdict.py +0 -0
  105. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/hunter/watches.py +0 -0
  106. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/mcp/__init__.py +0 -0
  107. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/mcp/_entry.py +0 -0
  108. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/__init__.py +0 -0
  109. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/account.py +0 -0
  110. {walla_cli-0.1.9 → walla_cli-0.1.10}/walla/models/listing.py +0 -0
  111. {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.9
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
- ![walla demo](docs/assets/walla-demo.mp4)
58
+ [![walla sell demo](docs/assets/walla-demo-preview.gif)](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
59
59
 
60
- Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
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
- ![walla demo](docs/assets/walla-demo.mp4)
14
+ [![walla sell demo](docs/assets/walla-demo-preview.gif)](https://github.com/berendgort/wallapop-cli/releases/download/demo/walla-demo.mp4)
15
15
 
16
- Demo music: [Funkee Monkeee](https://mixkit.co/free-stock-music/funkee-monkeee-1140/) by Michael Ramir C.
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` — top-level tree.
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
 
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "Wallapop.es CLI - look · offer · talk · sell for humans and agents"
5
5
  authors = [{ name = "Dr. Berend Gort", email = "berend.gort@gmail.com" }]
6
6
  readme = "README.md"
@@ -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 10105 --root 12579 --condition good \
25
+ --category escritorio --condition good \
25
26
  --floor 35 --yes --json
26
27
  ```
27
28
 
28
- `--floor` is the lowest EUR desk may accept. Omit it to hold the asking price.
29
- Category ids come from `walla categories --json`.
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")
@@ -36,3 +36,5 @@ def test_parse_categories_fixture() -> None:
36
36
  assert len(cats) >= 1
37
37
  assert cats[0].id
38
38
  assert cats[0].name
39
+ hogar = next(c for c in cats if c.id == 12467)
40
+ assert any(s.name == "Muebles y organización" for s in hogar.subcategories)
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ("__version__",)
6
6
 
7
- __version__ = "0.1.9"
7
+ __version__ = "0.1.10"
@@ -51,11 +51,14 @@ SELL_QUESTIONS = (
51
51
  },
52
52
  {
53
53
  "id": "category_leaf_id",
54
- "ask": "Leaf category id (string). Use walla categories --json to pick.",
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 (string), e.g. 12579 for Deporte y ocio.",
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(json: bool = typer.Option(False, "--json")) -> None:
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(None, "--category", help="Leaf category id"),
27
- root: str | None = typer.Option(None, "--root", help="Root category id"),
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": category,
49
- "root_category_id": root,
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
- "Pick category ids via walla categories --json."
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 category and root
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=category,
90
- root_category_id=root,
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 {"status": "published", "floor_eur": lowest, **created}
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
- return _wrap(
88
- lambda: {"categories": [c.model_dump(mode="json") for c in get_categories()]}
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]:
@@ -20,6 +20,7 @@ class Category(BaseModel):
20
20
  name: str
21
21
  icon: str | None = None
22
22
  vertical_id: str | None = None
23
+ subcategories: list[Category] = Field(default_factory=list)
23
24
 
24
25
 
25
26
  class Profile(BaseModel):
@@ -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
- out: list[Category] = []
139
- for c in cats:
140
- if not isinstance(c, dict):
141
- continue
142
- out.append(
143
- Category(
144
- id=int(c["id"]),
145
- name=str(c.get("name") or ""),
146
- icon=c.get("icon"),
147
- vertical_id=c.get("vertical_id"),
148
- )
149
- )
150
- return out
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