walla-cli 0.1.11__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.11 → walla_cli-0.1.12}/PKG-INFO +1 -1
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/WIRE.md +2 -1
- {walla_cli-0.1.11 → walla_cli-0.1.12}/pyproject.toml +1 -1
- {walla_cli-0.1.11 → walla_cli-0.1.12}/skills/walla-sell/SKILL.md +3 -2
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_sell.py +57 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/__init__.py +1 -1
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell.py +58 -50
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell_suggest.py +1 -1
- walla_cli-0.1.12/walla/account/sell_upload.py +51 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_sell.py +53 -18
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/instruct.py +1 -1
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/category_find.py +4 -4
- {walla_cli-0.1.11 → walla_cli-0.1.12}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/.gitignore +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/AGENTS.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/LICENSE +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/README.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-demo-poster.jpg +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-demo-preview.gif +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-demo.mp4 +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/code_quality.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/mcp.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/objective_function.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/categories.json +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/offer_buyer_request.json +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/sell_item_request.json +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/sell_suggest_draft.json +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/fixtures/wire_contracts.json +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/scripts/check_code_quality.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/scripts/dogfood_run.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/skills/walla/SKILL.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/skills/walla-buy/SKILL.md +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_account.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_account_more.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_catch.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_categories_find.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_chat_open.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_core.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_desk.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_edges.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_export.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_live.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_negotiate.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_parsers.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_polite.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_regressions.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_session_store.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_shortlist.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_suggest_watch.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_verdict.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/tests/test_wire_contracts.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/uv.lock +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/actions.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/chat.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/cookies.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/desk.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/desk_watch.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/inbox.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/login.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/offer_wire.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/prompt.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell_steps.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/sell_wire.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/banner.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_account.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_desk.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_look.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/cmd_ops.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/console.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/cli/main.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/errors.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/core/path.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/client.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/headers.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/http/polite.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/converge.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/export.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/export_pdf.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/negotiate.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/profile_store.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/pursuits.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/shortlist.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/stack.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/verdict.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/mcp/server.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/account.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/listing.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/models/profile.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/__init__.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/api.py +0 -0
- {walla_cli-0.1.11 → walla_cli-0.1.12}/walla/search/parse.py +0 -0
- {walla_cli-0.1.11 → 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
|
|
@@ -36,7 +36,8 @@ optional.
|
|
|
36
36
|
|
|
37
37
|
After `current_step=photo`, the steps `draft` may include `category_leaf_id`
|
|
38
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 …
|
|
39
|
+
empty HTTP 200; still poll before `loading`. CLI: `walla sell … --title …
|
|
40
|
+
--suggest` (explicit only; draft without `--suggest` does not upload).
|
|
40
41
|
`walla desk --watch` polls the inbox until `stack.best` converges.
|
|
41
42
|
|
|
42
43
|
## Auth
|
|
@@ -17,8 +17,9 @@ walla categories --find escritorio --json
|
|
|
17
17
|
walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
`--suggest`
|
|
21
|
-
`
|
|
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:
|
|
22
23
|
|
|
23
24
|
```bash
|
|
24
25
|
walla sell ./shot1.jpg ./shot2.jpg \
|
|
@@ -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
|
|
|
@@ -16,6 +16,7 @@ from walla.account.sell_steps import (
|
|
|
16
16
|
poll_suggested,
|
|
17
17
|
post_step,
|
|
18
18
|
)
|
|
19
|
+
from walla.account.sell_upload import IMAGE_TYPES, upload_pictures
|
|
19
20
|
from walla.account.sell_wire import build_item_body
|
|
20
21
|
from walla.core.exceptions import (
|
|
21
22
|
WallaHTTPError,
|
|
@@ -33,45 +34,11 @@ __all__ = (
|
|
|
33
34
|
"create_listing",
|
|
34
35
|
"delete_listing",
|
|
35
36
|
"publish_listing",
|
|
37
|
+
"publish_prepared",
|
|
36
38
|
"upload_pictures",
|
|
37
39
|
)
|
|
38
40
|
|
|
39
41
|
_UPLOAD_ACCEPT = "application/vnd.upload-v2+json"
|
|
40
|
-
_IMAGE_TYPES = {
|
|
41
|
-
".jpg": "image/jpeg",
|
|
42
|
-
".jpeg": "image/jpeg",
|
|
43
|
-
".png": "image/png",
|
|
44
|
-
".webp": "image/webp",
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def upload_pictures(upload_id: str, photos: list[Path]) -> int:
|
|
49
|
-
"""POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
|
|
50
|
-
if not photos:
|
|
51
|
-
raise ValueError("at least one photo required")
|
|
52
|
-
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
53
|
-
session: Any = requests.Session()
|
|
54
|
-
for path in photos:
|
|
55
|
-
data = path.read_bytes()
|
|
56
|
-
ctype = _IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
|
|
57
|
-
wait_turn()
|
|
58
|
-
mp = CurlMime()
|
|
59
|
-
mp.addpart(name="file", content_type=ctype, filename=path.name, data=data)
|
|
60
|
-
resp: Any = session.request(
|
|
61
|
-
"POST",
|
|
62
|
-
f"{API_BASE}/api/v3/upload/{upload_id}/pictures",
|
|
63
|
-
headers=headers,
|
|
64
|
-
multipart=mp,
|
|
65
|
-
impersonate="chrome",
|
|
66
|
-
timeout=60,
|
|
67
|
-
)
|
|
68
|
-
mp.close()
|
|
69
|
-
if resp.status_code not in (200, 201, 204):
|
|
70
|
-
raise WallaHTTPError(
|
|
71
|
-
f"picture upload HTTP {resp.status_code}: {(resp.text or '')[:200]}",
|
|
72
|
-
status_code=resp.status_code,
|
|
73
|
-
)
|
|
74
|
-
return len(photos)
|
|
75
42
|
|
|
76
43
|
|
|
77
44
|
def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
|
|
@@ -79,7 +46,7 @@ def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
|
|
|
79
46
|
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
80
47
|
headers["Accept"] = _UPLOAD_ACCEPT
|
|
81
48
|
data = first_photo.read_bytes()
|
|
82
|
-
ctype =
|
|
49
|
+
ctype = IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
|
|
83
50
|
wait_turn()
|
|
84
51
|
mp = CurlMime()
|
|
85
52
|
mp.addpart(name="image", content_type=ctype, filename=first_photo.name, data=data)
|
|
@@ -110,6 +77,54 @@ def delete_listing(item_id: str, *, client: HttpClient | None = None) -> dict[st
|
|
|
110
77
|
return {"id": item_id, "deleted": True}
|
|
111
78
|
|
|
112
79
|
|
|
80
|
+
def publish_prepared(
|
|
81
|
+
photos: list[Path],
|
|
82
|
+
*,
|
|
83
|
+
upload_id: str,
|
|
84
|
+
title: str,
|
|
85
|
+
description: str,
|
|
86
|
+
price_eur: float,
|
|
87
|
+
category_leaf_id: str,
|
|
88
|
+
lat: float,
|
|
89
|
+
lon: float,
|
|
90
|
+
condition: str = "good",
|
|
91
|
+
shipping: bool = False,
|
|
92
|
+
weight_kg: float | None = None,
|
|
93
|
+
wire_draft: dict[str, str] | None = None,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Create item from an upload_id already walked through loading."""
|
|
96
|
+
paths = [Path(p) for p in photos]
|
|
97
|
+
for p in paths:
|
|
98
|
+
if not p.is_file():
|
|
99
|
+
raise ValueError(f"photo not found: {p}")
|
|
100
|
+
if not upload_id.strip():
|
|
101
|
+
raise ValueError("upload_id required")
|
|
102
|
+
item = build_item_body(
|
|
103
|
+
upload_id=upload_id,
|
|
104
|
+
title=title,
|
|
105
|
+
description=description,
|
|
106
|
+
price_eur=price_eur,
|
|
107
|
+
category_leaf_id=category_leaf_id,
|
|
108
|
+
lat=lat,
|
|
109
|
+
lon=lon,
|
|
110
|
+
condition=condition,
|
|
111
|
+
shipping=shipping,
|
|
112
|
+
max_weight_kg=weight_kg,
|
|
113
|
+
)
|
|
114
|
+
created = create_listing(item, paths[0])
|
|
115
|
+
if len(paths) > 1:
|
|
116
|
+
_upload_extra_pictures(created["id"], paths[1:])
|
|
117
|
+
return {
|
|
118
|
+
**created,
|
|
119
|
+
"upload_id": upload_id,
|
|
120
|
+
"title": title.strip()[:50],
|
|
121
|
+
"url": _public_item_url(str(created["id"])),
|
|
122
|
+
"photos": len(paths),
|
|
123
|
+
"wire_draft": wire_draft or {},
|
|
124
|
+
"reused_suggest": True,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
113
128
|
def publish_listing(
|
|
114
129
|
photos: list[Path],
|
|
115
130
|
*,
|
|
@@ -143,7 +158,8 @@ def publish_listing(
|
|
|
143
158
|
post_step(http, upload_id, current="category", draft=draft)
|
|
144
159
|
poll_suggested(http, upload_id)
|
|
145
160
|
post_step(http, upload_id, current="loading", draft=draft)
|
|
146
|
-
|
|
161
|
+
out = publish_prepared(
|
|
162
|
+
paths,
|
|
147
163
|
upload_id=upload_id,
|
|
148
164
|
title=title,
|
|
149
165
|
description=description,
|
|
@@ -153,19 +169,11 @@ def publish_listing(
|
|
|
153
169
|
lon=lon,
|
|
154
170
|
condition=condition,
|
|
155
171
|
shipping=shipping,
|
|
156
|
-
|
|
172
|
+
weight_kg=weight_kg,
|
|
173
|
+
wire_draft=wire,
|
|
157
174
|
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
_upload_extra_pictures(created["id"], paths[1:])
|
|
161
|
-
return {
|
|
162
|
-
**created,
|
|
163
|
-
"upload_id": upload_id,
|
|
164
|
-
"title": title.strip()[:50],
|
|
165
|
-
"url": _public_item_url(str(created["id"])),
|
|
166
|
-
"photos": len(paths),
|
|
167
|
-
"wire_draft": wire,
|
|
168
|
-
}
|
|
175
|
+
out["reused_suggest"] = False
|
|
176
|
+
return out
|
|
169
177
|
|
|
170
178
|
|
|
171
179
|
def _public_item_url(item_id: str) -> str:
|
|
@@ -194,7 +202,7 @@ def _upload_extra_pictures(item_id: str, photos: list[Path]) -> None:
|
|
|
194
202
|
session: Any = requests.Session()
|
|
195
203
|
for idx, path in enumerate(photos, start=1):
|
|
196
204
|
data = path.read_bytes()
|
|
197
|
-
ctype =
|
|
205
|
+
ctype = IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
|
|
198
206
|
wait_turn()
|
|
199
207
|
mp = CurlMime()
|
|
200
208
|
mp.addpart(name="image", content_type=ctype, filename=path.name, data=data)
|
|
@@ -6,13 +6,13 @@ import uuid
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
-
from walla.account.sell import upload_pictures
|
|
10
9
|
from walla.account.sell_steps import (
|
|
11
10
|
auth_client,
|
|
12
11
|
parse_steps_draft,
|
|
13
12
|
poll_suggested,
|
|
14
13
|
post_step,
|
|
15
14
|
)
|
|
15
|
+
from walla.account.sell_upload import upload_pictures
|
|
16
16
|
from walla.search.api import get_categories
|
|
17
17
|
from walla.search.category_find import flatten_categories, resolve_root_id
|
|
18
18
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Multipart picture upload for Vender (/api/v3/upload/{id}/pictures)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from curl_cffi import CurlMime, requests
|
|
9
|
+
|
|
10
|
+
from walla.account.sell_steps import auth_headers
|
|
11
|
+
from walla.core.exceptions import WallaHTTPError
|
|
12
|
+
from walla.http.headers import API_BASE
|
|
13
|
+
from walla.http.polite import wait_turn
|
|
14
|
+
|
|
15
|
+
__all__ = ("IMAGE_TYPES", "upload_pictures")
|
|
16
|
+
|
|
17
|
+
IMAGE_TYPES = {
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".jpeg": "image/jpeg",
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".webp": "image/webp",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def upload_pictures(upload_id: str, photos: list[Path]) -> int:
|
|
26
|
+
"""POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
|
|
27
|
+
if not photos:
|
|
28
|
+
raise ValueError("at least one photo required")
|
|
29
|
+
headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
|
|
30
|
+
session: Any = requests.Session()
|
|
31
|
+
for path in photos:
|
|
32
|
+
data = path.read_bytes()
|
|
33
|
+
ctype = IMAGE_TYPES.get(path.suffix.lower(), "image/jpeg")
|
|
34
|
+
wait_turn()
|
|
35
|
+
mp = CurlMime()
|
|
36
|
+
mp.addpart(name="file", content_type=ctype, filename=path.name, data=data)
|
|
37
|
+
resp: Any = session.request(
|
|
38
|
+
"POST",
|
|
39
|
+
f"{API_BASE}/api/v3/upload/{upload_id}/pictures",
|
|
40
|
+
headers=headers,
|
|
41
|
+
multipart=mp,
|
|
42
|
+
impersonate="chrome",
|
|
43
|
+
timeout=60,
|
|
44
|
+
)
|
|
45
|
+
mp.close()
|
|
46
|
+
if resp.status_code not in (200, 201, 204):
|
|
47
|
+
raise WallaHTTPError(
|
|
48
|
+
f"picture upload HTTP {resp.status_code}: {(resp.text or '')[:200]}",
|
|
49
|
+
status_code=resp.status_code,
|
|
50
|
+
)
|
|
51
|
+
return len(photos)
|
|
@@ -7,7 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from walla.account.sell import delete_listing, publish_listing
|
|
10
|
+
from walla.account.sell import delete_listing, publish_listing, publish_prepared
|
|
11
11
|
from walla.account.sell_suggest import suggest_from_photos
|
|
12
12
|
from walla.account.sell_wire import CONDITIONS, SELL_QUESTIONS, missing_sell_fields
|
|
13
13
|
from walla.cli.catch import run_cmd
|
|
@@ -41,7 +41,7 @@ def register(app: typer.Typer) -> None:
|
|
|
41
41
|
suggest: bool = typer.Option(
|
|
42
42
|
False,
|
|
43
43
|
"--suggest",
|
|
44
|
-
help="Prefill category via Wallapop steps (needs --title)",
|
|
44
|
+
help="Prefill category via Wallapop steps (needs --title; uploads photos)",
|
|
45
45
|
),
|
|
46
46
|
yes: bool = typer.Option(False, "--yes", help="Publish for real"),
|
|
47
47
|
json: bool = typer.Option(False, "--json"),
|
|
@@ -58,8 +58,7 @@ def register(app: typer.Typer) -> None:
|
|
|
58
58
|
root_id = root
|
|
59
59
|
picked = None
|
|
60
60
|
suggested = None
|
|
61
|
-
|
|
62
|
-
if suggest or auto:
|
|
61
|
+
if suggest:
|
|
63
62
|
if not title:
|
|
64
63
|
raise ValueError("--suggest needs --title")
|
|
65
64
|
suggested = suggest_from_photos(
|
|
@@ -106,7 +105,7 @@ def register(app: typer.Typer) -> None:
|
|
|
106
105
|
},
|
|
107
106
|
"hint": (
|
|
108
107
|
"Fill missing fields, then walla sell <photos> … --yes. "
|
|
109
|
-
"
|
|
108
|
+
"Only --suggest uploads photos to prefill category."
|
|
110
109
|
),
|
|
111
110
|
}
|
|
112
111
|
if picked is not None:
|
|
@@ -126,21 +125,57 @@ def register(app: typer.Typer) -> None:
|
|
|
126
125
|
)
|
|
127
126
|
if profile.lat is None or profile.lon is None:
|
|
128
127
|
raise ValueError("Set location first: walla setup --lat … --lon …")
|
|
129
|
-
|
|
128
|
+
if not title_use or not description or eur is None or not leaf or not root_id:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"Cannot publish; need title, description, eur, category leaf, and root"
|
|
131
|
+
)
|
|
130
132
|
lowest = min(floor, eur) if floor is not None else eur
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
133
|
+
sug_leaf = (
|
|
134
|
+
str(suggested.get("category_leaf_id") or "") if suggested else ""
|
|
135
|
+
)
|
|
136
|
+
reuse_id = (
|
|
137
|
+
str(suggested["upload_id"])
|
|
138
|
+
if (
|
|
139
|
+
suggested
|
|
140
|
+
and suggested.get("upload_id")
|
|
141
|
+
and sug_leaf
|
|
142
|
+
and sug_leaf == str(leaf)
|
|
143
|
+
)
|
|
144
|
+
else ""
|
|
143
145
|
)
|
|
146
|
+
if reuse_id:
|
|
147
|
+
created = publish_prepared(
|
|
148
|
+
paths,
|
|
149
|
+
upload_id=reuse_id,
|
|
150
|
+
title=title_use,
|
|
151
|
+
description=description,
|
|
152
|
+
price_eur=eur,
|
|
153
|
+
category_leaf_id=str(leaf),
|
|
154
|
+
lat=profile.lat,
|
|
155
|
+
lon=profile.lon,
|
|
156
|
+
condition=condition,
|
|
157
|
+
shipping=ship,
|
|
158
|
+
weight_kg=weight_kg,
|
|
159
|
+
wire_draft={
|
|
160
|
+
"title": title_use,
|
|
161
|
+
"category_leaf_id": str(leaf),
|
|
162
|
+
"root_category_id": str(root_id),
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
created = publish_listing(
|
|
167
|
+
paths,
|
|
168
|
+
title=title_use,
|
|
169
|
+
description=description,
|
|
170
|
+
price_eur=eur,
|
|
171
|
+
category_leaf_id=str(leaf),
|
|
172
|
+
root_category_id=str(root_id),
|
|
173
|
+
lat=profile.lat,
|
|
174
|
+
lon=profile.lon,
|
|
175
|
+
condition=condition,
|
|
176
|
+
shipping=ship,
|
|
177
|
+
weight_kg=weight_kg,
|
|
178
|
+
)
|
|
144
179
|
remember(
|
|
145
180
|
Pursuit(
|
|
146
181
|
item_id=str(created["id"]),
|
|
@@ -147,7 +147,7 @@ def instruct_recipe() -> dict[str, Any]:
|
|
|
147
147
|
"desk --watch polls until stack.best converges; still never pay.",
|
|
148
148
|
"Bare say, offer, and sell still need --yes. Never pay or accept in walla.",
|
|
149
149
|
"Sell: drop photos, ask data.questions, then publish with --yes only.",
|
|
150
|
-
"Sell --suggest
|
|
150
|
+
"Sell --suggest uploads photos once to prefill category; draft without it is local.",
|
|
151
151
|
"If only asked to look, do not send.",
|
|
152
152
|
"Ambiguous match or multiple GRABs: ask once with candidates.",
|
|
153
153
|
"Default search is all of Spain. Shipping is enough; do not fence on profile.km.",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
6
|
|
|
7
7
|
from walla.models.profile import Category
|
|
8
8
|
|
|
@@ -14,8 +14,9 @@ __all__ = (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
class CategoryHit(BaseModel):
|
|
18
|
+
model_config = ConfigDict(frozen=True)
|
|
19
|
+
|
|
19
20
|
leaf_id: str
|
|
20
21
|
root_id: str
|
|
21
22
|
path: str
|
|
@@ -70,7 +71,6 @@ def find_categories(roots: list[Category], query: str) -> list[CategoryHit]:
|
|
|
70
71
|
elif name_l.startswith(tokens[-1]) or tokens[-1].startswith(_stem(name_l)):
|
|
71
72
|
score += 25
|
|
72
73
|
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
74
|
if any(_stem(part) == _stem(tokens[-1]) for part in blob.split(" / ")):
|
|
75
75
|
score += 15
|
|
76
76
|
scored.append((score, hit))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|