walla-cli 0.1.10__tar.gz → 0.1.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {walla_cli-0.1.10 → walla_cli-0.1.12}/PKG-INFO +1 -1
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/WIRE.md +6 -0
- walla_cli-0.1.12/fixtures/sell_suggest_draft.json +23 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/pyproject.toml +1 -1
- {walla_cli-0.1.10 → walla_cli-0.1.12}/skills/walla-sell/SKILL.md +10 -8
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_live.py +2 -1
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_sell.py +71 -13
- walla_cli-0.1.12/tests/test_suggest_watch.py +101 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_wire_contracts.py +3 -2
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/__init__.py +1 -1
- walla_cli-0.1.12/walla/account/desk_watch.py +36 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/sell.py +74 -99
- walla_cli-0.1.12/walla/account/sell_steps.py +82 -0
- walla_cli-0.1.12/walla/account/sell_suggest.py +77 -0
- walla_cli-0.1.12/walla/account/sell_upload.py +51 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_desk.py +12 -2
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_sell.py +79 -18
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/instruct.py +4 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/mcp/server.py +10 -2
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/category_find.py +4 -4
- {walla_cli-0.1.10 → walla_cli-0.1.12}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/.gitignore +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/AGENTS.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/LICENSE +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/README.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-demo-poster.jpg +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-demo-preview.gif +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-demo.mp4 +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/code_quality.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/mcp.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/objective_function.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/categories.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/offer_buyer_request.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/sell_item_request.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/fixtures/wire_contracts.json +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/scripts/check_code_quality.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/scripts/dogfood_run.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/skills/walla/SKILL.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/skills/walla-buy/SKILL.md +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_account.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_account_more.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_catch.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_categories_find.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_chat_open.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_core.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_desk.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_edges.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_export.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_negotiate.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_parsers.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_polite.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_regressions.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_session_store.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_shortlist.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/tests/test_verdict.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/uv.lock +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/actions.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/chat.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/cookies.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/desk.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/inbox.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/login.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/offer_wire.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/prompt.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/sell_wire.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/banner.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_account.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_look.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/cmd_ops.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/console.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/cli/main.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/errors.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/core/path.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/client.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/headers.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/http/polite.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/converge.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/export.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/export_pdf.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/negotiate.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/profile_store.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/pursuits.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/shortlist.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/stack.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/verdict.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/account.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/listing.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/models/profile.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/__init__.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/api.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/parse.py +0 -0
- {walla_cli-0.1.10 → walla_cli-0.1.12}/walla/search/pick_category.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: walla-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.12
|
|
4
4
|
Summary: Wallapop.es CLI - look · offer · talk · sell for humans and agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/berendgort/wallapop-cli
|
|
@@ -34,6 +34,12 @@ Public URL: `https://es.wallapop.com/item/<web_slug>`.
|
|
|
34
34
|
`leaf_id` + `root_id`. Sell accepts `--category <leaf-or-name>`; `--root` is
|
|
35
35
|
optional.
|
|
36
36
|
|
|
37
|
+
After `current_step=photo`, the steps `draft` may include `category_leaf_id`
|
|
38
|
+
(title+photo hint). `GET /api/v3/suggested-item-data/{upload_id}` often returns
|
|
39
|
+
empty HTTP 200; still poll before `loading`. CLI: `walla sell … --title …
|
|
40
|
+
--suggest` (explicit only; draft without `--suggest` does not upload).
|
|
41
|
+
`walla desk --watch` polls the inbox until `stack.best` converges.
|
|
42
|
+
|
|
37
43
|
## Auth
|
|
38
44
|
|
|
39
45
|
`POST https://api.wallapop.com/api/v3/access/login` with
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"note": "After photo step, Wallapop may inject category_leaf_id from title+photos. suggested-item-data often returns empty HTTP 200.",
|
|
4
|
+
"after_photo": {
|
|
5
|
+
"step": {"id": "category", "components": []},
|
|
6
|
+
"draft": {
|
|
7
|
+
"title": "Escritorio madera oscura patas cromadas",
|
|
8
|
+
"category_leaf_id": "24208"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"after_loading": {
|
|
12
|
+
"step": {"id": "listing", "components": []},
|
|
13
|
+
"draft": {
|
|
14
|
+
"title": "Escritorio madera oscura patas cromadas",
|
|
15
|
+
"category_leaf_id": "24208",
|
|
16
|
+
"root_category_id": "12467"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"suggested_item_data": {
|
|
20
|
+
"http_status": 200,
|
|
21
|
+
"body": null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -14,10 +14,12 @@ Do not ask the human to type buyer replies. Desk sends those.
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
walla categories --find escritorio --json
|
|
17
|
-
walla sell ./shot1.jpg ./shot2.jpg --json
|
|
17
|
+
walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
`--suggest` uploads photos and asks Wallapop to prefill `category_leaf_id`.
|
|
21
|
+
Draft without `--suggest` stays local (no upload). Ask remaining
|
|
22
|
+
`data.questions`, then:
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
25
|
walla sell ./shot1.jpg ./shot2.jpg \
|
|
@@ -26,17 +28,17 @@ walla sell ./shot1.jpg ./shot2.jpg \
|
|
|
26
28
|
--floor 35 --yes --json
|
|
27
29
|
```
|
|
28
30
|
|
|
29
|
-
`--category` accepts a leaf id **or** name tokens
|
|
30
|
-
`--
|
|
31
|
-
|
|
31
|
+
`--category` accepts a leaf id **or** name tokens. `--root` is optional.
|
|
32
|
+
`--floor` is the lowest EUR desk may accept.
|
|
33
|
+
|
|
32
34
|
Then:
|
|
33
35
|
|
|
34
36
|
```bash
|
|
35
|
-
walla desk --json
|
|
37
|
+
walla desk --watch --seconds 45 --rounds 12 --json
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
Desk
|
|
39
|
-
|
|
40
|
+
Desk answers buyers until `stack.best` converges or rounds end. Show the title,
|
|
41
|
+
agreed EUR, `stack.why`, and the item link.
|
|
40
42
|
|
|
41
43
|
The human accepts the sale in the Wallapop app. `walla unsell <id> --yes` deletes
|
|
42
44
|
a listing. Never mark the item sold from walla.
|
|
@@ -11,7 +11,8 @@ import pytest
|
|
|
11
11
|
from walla.account.chat import open_conversation
|
|
12
12
|
from walla.account.inbox import list_conversations_raw
|
|
13
13
|
from walla.account.login import ensure_access_token, whoami
|
|
14
|
-
from walla.account.sell import
|
|
14
|
+
from walla.account.sell import delete_listing, publish_listing, upload_pictures
|
|
15
|
+
from walla.account.sell_steps import post_step as _step
|
|
15
16
|
from walla.account.session_store import load_session
|
|
16
17
|
from walla.http.client import HttpClient
|
|
17
18
|
from walla.http.polite import reset_polite
|
|
@@ -70,6 +70,63 @@ def test_missing_sell_fields() -> None:
|
|
|
70
70
|
assert CONDITIONS[0] == "as_good_as_new"
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_sell_draft_with_title_does_not_suggest(tmp_path: Path) -> None:
|
|
74
|
+
from typer.testing import CliRunner
|
|
75
|
+
|
|
76
|
+
from walla.cli.main import app
|
|
77
|
+
|
|
78
|
+
photo = tmp_path / "a.jpg"
|
|
79
|
+
photo.write_bytes(b"\xff\xd8\xff\xd9")
|
|
80
|
+
runner = CliRunner()
|
|
81
|
+
with (
|
|
82
|
+
patch("walla.cli.cmd_sell.load_profile") as lp,
|
|
83
|
+
patch("walla.cli.cmd_sell.suggest_from_photos") as sug,
|
|
84
|
+
):
|
|
85
|
+
profile = MagicMock()
|
|
86
|
+
profile.lat = 41.39
|
|
87
|
+
profile.lon = 2.17
|
|
88
|
+
profile.label = "BCN"
|
|
89
|
+
lp.return_value = profile
|
|
90
|
+
result = runner.invoke(
|
|
91
|
+
app, ["sell", str(photo), "--title", "Escritorio", "--json"]
|
|
92
|
+
)
|
|
93
|
+
assert result.exit_code == 0, result.output
|
|
94
|
+
sug.assert_not_called()
|
|
95
|
+
payload = json.loads(result.output)
|
|
96
|
+
assert payload["data"]["status"] == "draft"
|
|
97
|
+
assert "suggested" not in payload["data"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_publish_prepared_reuses_upload(tmp_path: Path) -> None:
|
|
101
|
+
from walla.account.sell import publish_prepared
|
|
102
|
+
|
|
103
|
+
photo = tmp_path / "a.jpg"
|
|
104
|
+
photo.write_bytes(b"\xff\xd8\xff\xd9")
|
|
105
|
+
listing = MagicMock()
|
|
106
|
+
listing.url = "https://es.wallapop.com/item/x"
|
|
107
|
+
listing.web_slug = "x"
|
|
108
|
+
with (
|
|
109
|
+
patch(
|
|
110
|
+
"walla.account.sell.create_listing",
|
|
111
|
+
return_value={"id": "abc", "flags": {}},
|
|
112
|
+
) as create,
|
|
113
|
+
patch("walla.account.sell.get_item", return_value=listing),
|
|
114
|
+
):
|
|
115
|
+
out = publish_prepared(
|
|
116
|
+
[photo],
|
|
117
|
+
upload_id="upload-1",
|
|
118
|
+
title="T",
|
|
119
|
+
description="D",
|
|
120
|
+
price_eur=10,
|
|
121
|
+
category_leaf_id="24208",
|
|
122
|
+
lat=41.0,
|
|
123
|
+
lon=2.0,
|
|
124
|
+
)
|
|
125
|
+
assert out["reused_suggest"] is True
|
|
126
|
+
assert out["upload_id"] == "upload-1"
|
|
127
|
+
create.assert_called_once()
|
|
128
|
+
|
|
129
|
+
|
|
73
130
|
def test_sell_cmd_draft_without_yes(tmp_path: Path) -> None:
|
|
74
131
|
from typer.testing import CliRunner
|
|
75
132
|
|
|
@@ -119,23 +176,23 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
|
|
|
119
176
|
photo.write_bytes(b"\xff\xd8\xff\xd9")
|
|
120
177
|
fake_http = MagicMock()
|
|
121
178
|
fake_http.post.side_effect = [
|
|
122
|
-
{"step": {"id": "title"}},
|
|
123
|
-
{"step": {"id": "photo", "
|
|
124
|
-
{"step": {"id": "category"}},
|
|
179
|
+
{"step": {"id": "title"}, "draft": {}},
|
|
180
|
+
{"step": {"id": "photo"}, "draft": {"title": "Test item"}},
|
|
125
181
|
{
|
|
126
|
-
"step": {
|
|
127
|
-
|
|
128
|
-
"poll_before_next_step": {"path": "/api/v3/suggested-item-data/x"},
|
|
129
|
-
}
|
|
182
|
+
"step": {"id": "category"},
|
|
183
|
+
"draft": {"title": "Test item", "category_leaf_id": "10105"},
|
|
130
184
|
},
|
|
131
|
-
{"step": {"id": "
|
|
185
|
+
{"step": {"id": "loading"}, "draft": {"title": "Test item"}},
|
|
186
|
+
{"step": {"id": "listing"}, "draft": {"title": "Test item"}},
|
|
132
187
|
]
|
|
133
188
|
fake_http.get.return_value = {}
|
|
134
189
|
listing = MagicMock()
|
|
135
190
|
listing.url = "https://es.wallapop.com/item/test-item-99"
|
|
136
191
|
listing.web_slug = "test-item-99"
|
|
137
192
|
with (
|
|
138
|
-
patch("walla.account.sell.
|
|
193
|
+
patch("walla.account.sell.auth_client", return_value=fake_http),
|
|
194
|
+
patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
|
|
195
|
+
patch("walla.account.sell.poll_suggested", return_value=None),
|
|
139
196
|
patch("walla.account.sell.upload_pictures", return_value=1),
|
|
140
197
|
patch(
|
|
141
198
|
"walla.account.sell.create_listing",
|
|
@@ -156,7 +213,6 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
|
|
|
156
213
|
assert out["id"] == "abc123"
|
|
157
214
|
assert out["photos"] == 1
|
|
158
215
|
assert out["url"] == "https://es.wallapop.com/item/test-item-99"
|
|
159
|
-
assert fake_http.post.call_count == 5
|
|
160
216
|
|
|
161
217
|
|
|
162
218
|
def test_create_listing_multipart(tmp_path: Path) -> None:
|
|
@@ -179,7 +235,7 @@ def test_create_listing_multipart(tmp_path: Path) -> None:
|
|
|
179
235
|
fake_session = MagicMock()
|
|
180
236
|
fake_session.request.return_value = fake_resp
|
|
181
237
|
with (
|
|
182
|
-
patch("walla.account.sell.
|
|
238
|
+
patch("walla.account.sell.auth_headers", return_value={"Authorization": "Bearer x"}),
|
|
183
239
|
patch("walla.account.sell.requests.Session", return_value=fake_session),
|
|
184
240
|
patch("walla.account.sell.CurlMime") as mime_cls,
|
|
185
241
|
patch("walla.account.sell.wait_turn"),
|
|
@@ -227,14 +283,16 @@ def test_publish_listing_two_photos(tmp_path: Path) -> None:
|
|
|
227
283
|
listing.url = "https://es.wallapop.com/item/test-item-99"
|
|
228
284
|
listing.web_slug = "test-item-99"
|
|
229
285
|
with (
|
|
230
|
-
patch("walla.account.sell.
|
|
286
|
+
patch("walla.account.sell.auth_client", return_value=fake_http),
|
|
287
|
+
patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
|
|
288
|
+
patch("walla.account.sell.poll_suggested", return_value=None),
|
|
231
289
|
patch("walla.account.sell.upload_pictures", return_value=2),
|
|
232
290
|
patch(
|
|
233
291
|
"walla.account.sell.create_listing",
|
|
234
292
|
return_value={"id": "abc123", "flags": {}},
|
|
235
293
|
),
|
|
236
294
|
patch("walla.account.sell.get_item", return_value=listing),
|
|
237
|
-
patch("walla.account.sell.
|
|
295
|
+
patch("walla.account.sell.auth_headers", return_value={}),
|
|
238
296
|
patch("walla.account.sell.requests.Session", return_value=fake_session),
|
|
239
297
|
patch("walla.account.sell.CurlMime") as mime_cls,
|
|
240
298
|
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,14 @@ 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
|
+
)
|
|
19
|
+
from walla.account.sell_upload import IMAGE_TYPES, upload_pictures
|
|
13
20
|
from walla.account.sell_wire import build_item_body
|
|
14
21
|
from walla.core.exceptions import (
|
|
15
22
|
WallaHTTPError,
|
|
@@ -18,7 +25,7 @@ from walla.core.exceptions import (
|
|
|
18
25
|
WallaUnsupportedError,
|
|
19
26
|
)
|
|
20
27
|
from walla.http.client import HttpClient
|
|
21
|
-
from walla.http.headers import API_BASE, WEB_BASE
|
|
28
|
+
from walla.http.headers import API_BASE, WEB_BASE
|
|
22
29
|
from walla.http.polite import wait_turn
|
|
23
30
|
from walla.search.api import get_item
|
|
24
31
|
from walla.search.parse import item_url
|
|
@@ -27,84 +34,19 @@ __all__ = (
|
|
|
27
34
|
"create_listing",
|
|
28
35
|
"delete_listing",
|
|
29
36
|
"publish_listing",
|
|
37
|
+
"publish_prepared",
|
|
30
38
|
"upload_pictures",
|
|
31
39
|
)
|
|
32
40
|
|
|
33
41
|
_UPLOAD_ACCEPT = "application/vnd.upload-v2+json"
|
|
34
|
-
_IMAGE_TYPES = {
|
|
35
|
-
".jpg": "image/jpeg",
|
|
36
|
-
".jpeg": "image/jpeg",
|
|
37
|
-
".png": "image/png",
|
|
38
|
-
".webp": "image/webp",
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
def upload_pictures(upload_id: str, photos: list[Path]) -> int:
|
|
74
|
-
"""POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
|
|
75
|
-
if not photos:
|
|
76
|
-
raise ValueError("at least one photo required")
|
|
77
|
-
headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
|
|
78
|
-
session: Any = requests.Session()
|
|
79
|
-
for path in photos:
|
|
80
|
-
data = path.read_bytes()
|
|
81
|
-
ctype = _IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
|
|
82
|
-
wait_turn()
|
|
83
|
-
mp = CurlMime()
|
|
84
|
-
mp.addpart(name="file", content_type=ctype, filename=path.name, data=data)
|
|
85
|
-
resp: Any = session.request(
|
|
86
|
-
"POST",
|
|
87
|
-
f"{API_BASE}/api/v3/upload/{upload_id}/pictures",
|
|
88
|
-
headers=headers,
|
|
89
|
-
multipart=mp,
|
|
90
|
-
impersonate="chrome",
|
|
91
|
-
timeout=60,
|
|
92
|
-
)
|
|
93
|
-
mp.close()
|
|
94
|
-
if resp.status_code not in (200, 201, 204):
|
|
95
|
-
raise WallaHTTPError(
|
|
96
|
-
f"picture upload HTTP {resp.status_code}: {(resp.text or '')[:200]}",
|
|
97
|
-
status_code=resp.status_code,
|
|
98
|
-
)
|
|
99
|
-
return len(photos)
|
|
100
42
|
|
|
101
43
|
|
|
102
44
|
def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
|
|
103
45
|
"""Multipart POST /api/v3/items with Accept upload-v2."""
|
|
104
|
-
headers = {k: v for k, v in
|
|
46
|
+
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
105
47
|
headers["Accept"] = _UPLOAD_ACCEPT
|
|
106
48
|
data = first_photo.read_bytes()
|
|
107
|
-
ctype =
|
|
49
|
+
ctype = IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
|
|
108
50
|
wait_turn()
|
|
109
51
|
mp = CurlMime()
|
|
110
52
|
mp.addpart(name="image", content_type=ctype, filename=first_photo.name, data=data)
|
|
@@ -130,52 +72,33 @@ def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
|
|
|
130
72
|
|
|
131
73
|
|
|
132
74
|
def delete_listing(item_id: str, *, client: HttpClient | None = None) -> dict[str, Any]:
|
|
133
|
-
http = client or
|
|
75
|
+
http = client or auth_client()
|
|
134
76
|
http.delete(f"/api/v3/items/{item_id}", auth=True)
|
|
135
77
|
return {"id": item_id, "deleted": True}
|
|
136
78
|
|
|
137
79
|
|
|
138
|
-
def
|
|
80
|
+
def publish_prepared(
|
|
139
81
|
photos: list[Path],
|
|
140
82
|
*,
|
|
83
|
+
upload_id: str,
|
|
141
84
|
title: str,
|
|
142
85
|
description: str,
|
|
143
86
|
price_eur: float,
|
|
144
87
|
category_leaf_id: str,
|
|
145
|
-
root_category_id: str,
|
|
146
88
|
lat: float,
|
|
147
89
|
lon: float,
|
|
148
90
|
condition: str = "good",
|
|
149
91
|
shipping: bool = False,
|
|
150
92
|
weight_kg: float | None = None,
|
|
93
|
+
wire_draft: dict[str, str] | None = None,
|
|
151
94
|
) -> dict[str, Any]:
|
|
152
|
-
"""
|
|
95
|
+
"""Create item from an upload_id already walked through loading."""
|
|
153
96
|
paths = [Path(p) for p in photos]
|
|
154
97
|
for p in paths:
|
|
155
98
|
if not p.is_file():
|
|
156
99
|
raise ValueError(f"photo not found: {p}")
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
draft: dict[str, Any] = {"title": title.strip()[:50]}
|
|
160
|
-
_step(http, upload_id, current=None, draft={})
|
|
161
|
-
_step(http, upload_id, current="title", draft=draft)
|
|
162
|
-
upload_pictures(upload_id, paths)
|
|
163
|
-
_step(http, upload_id, current="photo", draft=draft)
|
|
164
|
-
draft = {
|
|
165
|
-
**draft,
|
|
166
|
-
"category_leaf_id": str(category_leaf_id),
|
|
167
|
-
"root_category_id": str(root_category_id),
|
|
168
|
-
}
|
|
169
|
-
_step(http, upload_id, current="category", draft=draft)
|
|
170
|
-
for _ in range(8):
|
|
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)
|
|
100
|
+
if not upload_id.strip():
|
|
101
|
+
raise ValueError("upload_id required")
|
|
179
102
|
item = build_item_body(
|
|
180
103
|
upload_id=upload_id,
|
|
181
104
|
title=title,
|
|
@@ -197,11 +120,63 @@ def publish_listing(
|
|
|
197
120
|
"title": title.strip()[:50],
|
|
198
121
|
"url": _public_item_url(str(created["id"])),
|
|
199
122
|
"photos": len(paths),
|
|
123
|
+
"wire_draft": wire_draft or {},
|
|
124
|
+
"reused_suggest": True,
|
|
200
125
|
}
|
|
201
126
|
|
|
202
127
|
|
|
128
|
+
def publish_listing(
|
|
129
|
+
photos: list[Path],
|
|
130
|
+
*,
|
|
131
|
+
title: str,
|
|
132
|
+
description: str,
|
|
133
|
+
price_eur: float,
|
|
134
|
+
category_leaf_id: str,
|
|
135
|
+
root_category_id: str,
|
|
136
|
+
lat: float,
|
|
137
|
+
lon: float,
|
|
138
|
+
condition: str = "good",
|
|
139
|
+
shipping: bool = False,
|
|
140
|
+
weight_kg: float | None = None,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Full Vender path. Caller must already require --yes."""
|
|
143
|
+
paths = [Path(p) for p in photos]
|
|
144
|
+
for p in paths:
|
|
145
|
+
if not p.is_file():
|
|
146
|
+
raise ValueError(f"photo not found: {p}")
|
|
147
|
+
http = auth_client()
|
|
148
|
+
upload_id = str(uuid.uuid4())
|
|
149
|
+
draft: dict[str, Any] = {"title": title.strip()[:50]}
|
|
150
|
+
post_step(http, upload_id, current=None, draft={})
|
|
151
|
+
post_step(http, upload_id, current="title", draft=draft)
|
|
152
|
+
upload_pictures(upload_id, paths)
|
|
153
|
+
after_photo = post_step(http, upload_id, current="photo", draft=draft)
|
|
154
|
+
wire = parse_steps_draft(after_photo)
|
|
155
|
+
leaf = str(category_leaf_id)
|
|
156
|
+
root = str(root_category_id)
|
|
157
|
+
draft = {**draft, "category_leaf_id": leaf, "root_category_id": root}
|
|
158
|
+
post_step(http, upload_id, current="category", draft=draft)
|
|
159
|
+
poll_suggested(http, upload_id)
|
|
160
|
+
post_step(http, upload_id, current="loading", draft=draft)
|
|
161
|
+
out = publish_prepared(
|
|
162
|
+
paths,
|
|
163
|
+
upload_id=upload_id,
|
|
164
|
+
title=title,
|
|
165
|
+
description=description,
|
|
166
|
+
price_eur=price_eur,
|
|
167
|
+
category_leaf_id=leaf,
|
|
168
|
+
lat=lat,
|
|
169
|
+
lon=lon,
|
|
170
|
+
condition=condition,
|
|
171
|
+
shipping=shipping,
|
|
172
|
+
weight_kg=weight_kg,
|
|
173
|
+
wire_draft=wire,
|
|
174
|
+
)
|
|
175
|
+
out["reused_suggest"] = False
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
203
179
|
def _public_item_url(item_id: str) -> str:
|
|
204
|
-
"""Wallapop web needs the slug path, not the opaque item hash."""
|
|
205
180
|
try:
|
|
206
181
|
listing = get_item(item_id)
|
|
207
182
|
except (
|
|
@@ -222,12 +197,12 @@ def _public_item_url(item_id: str) -> str:
|
|
|
222
197
|
|
|
223
198
|
|
|
224
199
|
def _upload_extra_pictures(item_id: str, photos: list[Path]) -> None:
|
|
225
|
-
headers = {k: v for k, v in
|
|
200
|
+
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
226
201
|
headers["Accept"] = _UPLOAD_ACCEPT
|
|
227
202
|
session: Any = requests.Session()
|
|
228
203
|
for idx, path in enumerate(photos, start=1):
|
|
229
204
|
data = path.read_bytes()
|
|
230
|
-
ctype =
|
|
205
|
+
ctype = IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
|
|
231
206
|
wait_turn()
|
|
232
207
|
mp = CurlMime()
|
|
233
208
|
mp.addpart(name="image", content_type=ctype, filename=path.name, data=data)
|