vinted-cli 0.1.0__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.
@@ -0,0 +1,34 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+ steps:
15
+ - name: Checkout
16
+ uses: actions/checkout@v4
17
+
18
+ - name: Set up Python
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Build distributions
24
+ run: |
25
+ python -m pip install --upgrade pip build
26
+ python -m build
27
+
28
+ - name: Check distributions
29
+ run: |
30
+ python -m pip install --upgrade twine
31
+ python -m twine check dist/*
32
+
33
+ - name: Publish to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ *.egg
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paatsu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: vinted-cli
3
+ Version: 0.1.0
4
+ Summary: Fast CLI for searching Vinted — optimized for agents and scripting
5
+ Project-URL: Homepage, https://github.com/Paatsu/vinted-cli
6
+ Project-URL: Repository, https://github.com/Paatsu/vinted-cli
7
+ Project-URL: Issues, https://github.com/Paatsu/vinted-cli/issues
8
+ Author: Paatsu
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,fashion,marketplace,search,sweden,vinted
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: click>=8.0
20
+ Requires-Dist: httpx>=0.27
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.9; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # vinted-cli
27
+
28
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://python.org)
29
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
30
+
31
+ Fast CLI for searching [Vinted](https://www.vinted.se).
32
+
33
+ Primary target is vinted.se but can be configured to work with any Vinted country. Designed for agents, scripts, and quick terminal lookups. Minimal dependencies, structured output.
34
+
35
+ ## Install
36
+
37
+ **From PyPI:**
38
+
39
+ ```bash
40
+ pip install vinted-cli
41
+ ```
42
+
43
+ **Upgrade:**
44
+
45
+ ```bash
46
+ pip install --upgrade vinted-cli
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ### Search listings
52
+
53
+ ```bash
54
+ vinted search "jeans"
55
+ vinted search "iphone 15" --price-max 5000
56
+ vinted search "nike" --condition very-good --sort price-asc
57
+ vinted search "dress M" --sort newest -n 10
58
+ vinted search "jacka" -o json | jq '.results[:3]'
59
+ vinted search --catalog-id 1231 # browse a category without a query
60
+ vinted search --price-max 100 --sort newest # all cheap new listings
61
+ ```
62
+
63
+ ### Get item details
64
+
65
+ ```bash
66
+ vinted item 1234567890
67
+ vinted item 1234567890 -o json
68
+ vinted item 1234567890 --country fr
69
+ ```
70
+
71
+ ### Browse filters
72
+
73
+ ```bash
74
+ vinted countries # list all supported country codes
75
+ vinted conditions # list item condition filters
76
+ vinted catalogs # list the full catalog tree
77
+ vinted catalogs --parent-id 2994 # list subcatalogs of Electronics
78
+ ```
79
+
80
+ ## Output formats
81
+
82
+ | Flag | Format | Use case |
83
+ |------|--------|----------|
84
+ | (default) | Human-readable table | Terminal browsing |
85
+ | `-o json` | Compact JSON | Piping to `jq`, API consumption |
86
+ | `-o jsonl` | One JSON object per line | Streaming, log processing |
87
+
88
+ ## Common options
89
+
90
+ All search commands support these shared options:
91
+
92
+ | Option | Description |
93
+ |--------|-------------|
94
+ | `--country` | Vinted country code (default: `se`, env: `VINTED_COUNTRY`) |
95
+ | `--price-min` | Minimum price |
96
+ | `--price-max` | Maximum price |
97
+ | `--condition` | Item condition filter |
98
+ | `--brand-id` | Numeric Vinted brand ID filter |
99
+ | `--size-id` | Numeric Vinted size ID filter |
100
+ | `--catalog-id` | Numeric Vinted catalog ID (category) filter |
101
+ | `--sort` | Sort order (`relevance`, `newest`, `oldest`, `price-asc`, `price-desc`) |
102
+ | `-n`, `--limit` | Max results to display |
103
+ | `-p`, `--page` | Page number |
104
+ | `-o`, `--output` | Output format (`table`, `json`, `jsonl`) |
105
+ | `--raw` | Full API response instead of slim fields |
106
+
107
+ ## Supported countries
108
+
109
+ `se`, `fr`, `de`, `uk`, `pl`, `be`, `nl`, `it`, `es`, `at`, `lu`, `pt`, `cz`, `hu`, `ro`, `sk`, `lt`, `lv`, `ee`
110
+
111
+ Use `vinted countries` to list all supported country codes.
112
+
113
+ ## Default country via environment variable
114
+
115
+ ```bash
116
+ export VINTED_COUNTRY=de
117
+ vinted search "jacke" # searches vinted.de
118
+ ```
119
+
120
+ ## Agent integration
121
+
122
+ The JSON output is designed for LLM agents and automation:
123
+
124
+ ```bash
125
+ # Slim search results for an agent
126
+ vinted search "nike air max" --sort price-asc -o json | jq '.results[:5]'
127
+
128
+ # Stream listings line by line
129
+ vinted search "vintage levi" -o jsonl
130
+
131
+ # Price analysis
132
+ vinted search "iphone 15" -o json | python3 -c "
133
+ import sys, json
134
+ data = json.load(sys.stdin)
135
+ prices = [float(r['price']) for r in data['results'] if r.get('price')]
136
+ print(f'Found {data[\"total\"]} listings')
137
+ print(f'Price range: {min(prices):.0f} - {max(prices):.0f}')
138
+ print(f'Average: {sum(prices)/len(prices):.0f}')
139
+ "
140
+
141
+ # Get full item details
142
+ vinted item 1234567890 -o json
143
+ ```
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,122 @@
1
+ # vinted-cli
2
+
3
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://python.org)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
5
+
6
+ Fast CLI for searching [Vinted](https://www.vinted.se).
7
+
8
+ Primary target is vinted.se but can be configured to work with any Vinted country. Designed for agents, scripts, and quick terminal lookups. Minimal dependencies, structured output.
9
+
10
+ ## Install
11
+
12
+ **From PyPI:**
13
+
14
+ ```bash
15
+ pip install vinted-cli
16
+ ```
17
+
18
+ **Upgrade:**
19
+
20
+ ```bash
21
+ pip install --upgrade vinted-cli
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Search listings
27
+
28
+ ```bash
29
+ vinted search "jeans"
30
+ vinted search "iphone 15" --price-max 5000
31
+ vinted search "nike" --condition very-good --sort price-asc
32
+ vinted search "dress M" --sort newest -n 10
33
+ vinted search "jacka" -o json | jq '.results[:3]'
34
+ vinted search --catalog-id 1231 # browse a category without a query
35
+ vinted search --price-max 100 --sort newest # all cheap new listings
36
+ ```
37
+
38
+ ### Get item details
39
+
40
+ ```bash
41
+ vinted item 1234567890
42
+ vinted item 1234567890 -o json
43
+ vinted item 1234567890 --country fr
44
+ ```
45
+
46
+ ### Browse filters
47
+
48
+ ```bash
49
+ vinted countries # list all supported country codes
50
+ vinted conditions # list item condition filters
51
+ vinted catalogs # list the full catalog tree
52
+ vinted catalogs --parent-id 2994 # list subcatalogs of Electronics
53
+ ```
54
+
55
+ ## Output formats
56
+
57
+ | Flag | Format | Use case |
58
+ |------|--------|----------|
59
+ | (default) | Human-readable table | Terminal browsing |
60
+ | `-o json` | Compact JSON | Piping to `jq`, API consumption |
61
+ | `-o jsonl` | One JSON object per line | Streaming, log processing |
62
+
63
+ ## Common options
64
+
65
+ All search commands support these shared options:
66
+
67
+ | Option | Description |
68
+ |--------|-------------|
69
+ | `--country` | Vinted country code (default: `se`, env: `VINTED_COUNTRY`) |
70
+ | `--price-min` | Minimum price |
71
+ | `--price-max` | Maximum price |
72
+ | `--condition` | Item condition filter |
73
+ | `--brand-id` | Numeric Vinted brand ID filter |
74
+ | `--size-id` | Numeric Vinted size ID filter |
75
+ | `--catalog-id` | Numeric Vinted catalog ID (category) filter |
76
+ | `--sort` | Sort order (`relevance`, `newest`, `oldest`, `price-asc`, `price-desc`) |
77
+ | `-n`, `--limit` | Max results to display |
78
+ | `-p`, `--page` | Page number |
79
+ | `-o`, `--output` | Output format (`table`, `json`, `jsonl`) |
80
+ | `--raw` | Full API response instead of slim fields |
81
+
82
+ ## Supported countries
83
+
84
+ `se`, `fr`, `de`, `uk`, `pl`, `be`, `nl`, `it`, `es`, `at`, `lu`, `pt`, `cz`, `hu`, `ro`, `sk`, `lt`, `lv`, `ee`
85
+
86
+ Use `vinted countries` to list all supported country codes.
87
+
88
+ ## Default country via environment variable
89
+
90
+ ```bash
91
+ export VINTED_COUNTRY=de
92
+ vinted search "jacke" # searches vinted.de
93
+ ```
94
+
95
+ ## Agent integration
96
+
97
+ The JSON output is designed for LLM agents and automation:
98
+
99
+ ```bash
100
+ # Slim search results for an agent
101
+ vinted search "nike air max" --sort price-asc -o json | jq '.results[:5]'
102
+
103
+ # Stream listings line by line
104
+ vinted search "vintage levi" -o jsonl
105
+
106
+ # Price analysis
107
+ vinted search "iphone 15" -o json | python3 -c "
108
+ import sys, json
109
+ data = json.load(sys.stdin)
110
+ prices = [float(r['price']) for r in data['results'] if r.get('price')]
111
+ print(f'Found {data[\"total\"]} listings')
112
+ print(f'Price range: {min(prices):.0f} - {max(prices):.0f}')
113
+ print(f'Average: {sum(prices)/len(prices):.0f}')
114
+ "
115
+
116
+ # Get full item details
117
+ vinted item 1234567890 -o json
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "vinted-cli"
3
+ version = "0.1.0"
4
+ description = "Fast CLI for searching Vinted — optimized for agents and scripting"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [{ name = "Paatsu" }]
9
+ keywords = ["vinted", "cli", "search", "marketplace", "sweden", "fashion"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Utilities",
17
+ ]
18
+ dependencies = [
19
+ "click>=8.0",
20
+ "httpx>=0.27",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/Paatsu/vinted-cli"
25
+ Repository = "https://github.com/Paatsu/vinted-cli"
26
+ Issues = "https://github.com/Paatsu/vinted-cli/issues"
27
+
28
+ [project.scripts]
29
+ vinted = "vinted-cli.cli:main"
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8.0", "ruff>=0.9"]
33
+
34
+ [tool.ruff]
35
+ target-version = "py310"
36
+ line-length = 120
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "UP", "B", "SIM"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+
44
+ [build-system]
45
+ requires = ["hatchling"]
46
+ build-backend = "hatchling.build"
@@ -0,0 +1,175 @@
1
+ """Tests for Vinted CLI commands (mocked HTTP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from unittest.mock import patch
7
+
8
+ from click.testing import CliRunner
9
+
10
+ from vinted_cli.cli import main
11
+
12
+ SEARCH_RESPONSE = {
13
+ "items": [
14
+ {
15
+ "id": 1234567890,
16
+ "title": "Nike Air Max 90",
17
+ "price": "499",
18
+ "currency": "SEK",
19
+ "brand_title": "Nike",
20
+ "size_title": "42",
21
+ "status": "Very good",
22
+ "url": "https://www.vinted.se/items/1234567890-nike-air-max-90",
23
+ "user": {"login": "seller123"},
24
+ "photo": {"url": "https://images.vinted.se/1.jpg", "full_size_url": "https://images.vinted.se/1_full.jpg"},
25
+ "description": "Great condition Nike shoes",
26
+ }
27
+ ],
28
+ "pagination": {
29
+ "current_page": 1,
30
+ "total_pages": 5,
31
+ "total_count": 98,
32
+ "per_page": 20,
33
+ },
34
+ }
35
+
36
+ ITEM_RESPONSE = {
37
+ "item": {
38
+ "id": 1234567890,
39
+ "title": "Nike Air Max 90",
40
+ "price": "499",
41
+ "currency": "SEK",
42
+ "brand_title": "Nike",
43
+ "size_title": "42",
44
+ "status": "Very good",
45
+ "url": "https://www.vinted.se/items/1234567890-nike-air-max-90",
46
+ "user": {"login": "seller123"},
47
+ "description": "Great condition Nike shoes",
48
+ }
49
+ }
50
+
51
+
52
+ def _mock_search(*args, **kwargs):
53
+ return SEARCH_RESPONSE
54
+
55
+
56
+ def _mock_get_item(*args, **kwargs):
57
+ return ITEM_RESPONSE
58
+
59
+
60
+ class TestSearchCommand:
61
+ def test_table_output(self):
62
+ with patch("vinted_cli.cli.api.search", _mock_search):
63
+ result = CliRunner().invoke(main, ["search", "nike"])
64
+ assert result.exit_code == 0
65
+ assert "Nike Air Max 90" in result.output
66
+ assert "499 SEK" in result.output
67
+
68
+ def test_json_output_is_slim(self):
69
+ with patch("vinted_cli.cli.api.search", _mock_search):
70
+ result = CliRunner().invoke(main, ["search", "nike", "-o", "json"])
71
+ assert result.exit_code == 0
72
+ data = json.loads(result.output)
73
+ assert data["total"] == 98
74
+ listing = data["results"][0]
75
+ assert listing["id"] == 1234567890
76
+ assert listing["title"] == "Nike Air Max 90"
77
+ assert listing["price"] == "499"
78
+ # Slim output should not include raw photo object
79
+ assert "photo" not in listing or isinstance(listing["photo"], str)
80
+ # Slim output should not include description
81
+ assert "description" not in listing
82
+
83
+ def test_json_raw_output(self):
84
+ with patch("vinted_cli.cli.api.search", _mock_search):
85
+ result = CliRunner().invoke(main, ["search", "nike", "-o", "json", "--raw"])
86
+ assert result.exit_code == 0
87
+ data = json.loads(result.output)
88
+ listing = data["results"][0]
89
+ assert "description" in listing
90
+ assert isinstance(listing["photo"], dict)
91
+
92
+ def test_jsonl_output(self):
93
+ with patch("vinted_cli.cli.api.search", _mock_search):
94
+ result = CliRunner().invoke(main, ["search", "nike", "-o", "jsonl"])
95
+ assert result.exit_code == 0
96
+ lines = [line for line in result.output.strip().splitlines() if line]
97
+ assert len(lines) == 1
98
+ item = json.loads(lines[0])
99
+ assert item["id"] == 1234567890
100
+
101
+ def test_limit(self):
102
+ with patch("vinted_cli.cli.api.search", _mock_search):
103
+ result = CliRunner().invoke(main, ["search", "nike", "-o", "json", "-n", "1"])
104
+ assert result.exit_code == 0
105
+ data = json.loads(result.output)
106
+ assert len(data["results"]) == 1
107
+
108
+ def test_invalid_country(self):
109
+ result = CliRunner().invoke(main, ["search", "nike", "--country", "xx"])
110
+ assert result.exit_code == 1
111
+ assert "Unknown country" in result.output
112
+
113
+ def test_invalid_condition(self):
114
+ result = CliRunner().invoke(main, ["search", "nike", "--condition", "terrible"])
115
+ assert result.exit_code != 0
116
+
117
+ def test_table_output_shows_seller(self):
118
+ with patch("vinted_cli.cli.api.search", _mock_search):
119
+ result = CliRunner().invoke(main, ["search", "nike"])
120
+ assert "seller123" in result.output
121
+
122
+ def test_catalog_id_passed_to_api(self):
123
+ captured = {}
124
+
125
+ def capture_search(*args, **kwargs):
126
+ captured.update(kwargs)
127
+ return SEARCH_RESPONSE
128
+
129
+ with patch("vinted_cli.cli.api.search", capture_search):
130
+ result = CliRunner().invoke(main, ["search", "nike", "--catalog-id", "1231"])
131
+ assert result.exit_code == 0
132
+ assert captured.get("catalog_id") == "1231"
133
+
134
+ def test_search_without_query(self):
135
+ captured = {}
136
+
137
+ def capture_search(*args, **kwargs):
138
+ captured["query"] = args[0] if args else kwargs.get("query", "")
139
+ return SEARCH_RESPONSE
140
+
141
+ with patch("vinted_cli.cli.api.search", capture_search):
142
+ result = CliRunner().invoke(main, ["search", "--catalog-id", "1231"])
143
+ assert result.exit_code == 0
144
+ assert captured.get("query") == ""
145
+
146
+
147
+ class TestItemCommand:
148
+ def test_table_output(self):
149
+ with patch("vinted_cli.cli.api.get_item", _mock_get_item):
150
+ result = CliRunner().invoke(main, ["item", "1234567890"])
151
+ assert result.exit_code == 0
152
+ assert "Nike Air Max 90" in result.output
153
+ assert "499 SEK" in result.output
154
+ assert "Nike" in result.output
155
+
156
+ def test_json_output(self):
157
+ with patch("vinted_cli.cli.api.get_item", _mock_get_item):
158
+ result = CliRunner().invoke(main, ["item", "1234567890", "-o", "json"])
159
+ assert result.exit_code == 0
160
+ data = json.loads(result.output)
161
+ assert data["item"]["title"] == "Nike Air Max 90"
162
+
163
+
164
+ class TestInfoCommands:
165
+ def test_countries(self):
166
+ result = CliRunner().invoke(main, ["countries"])
167
+ assert result.exit_code == 0
168
+ assert "se" in result.output
169
+ assert "vinted.se" in result.output
170
+
171
+ def test_conditions(self):
172
+ result = CliRunner().invoke(main, ["conditions"])
173
+ assert result.exit_code == 0
174
+ assert "very-good" in result.output
175
+ assert "Very good" in result.output
@@ -0,0 +1 @@
1
+ """Vinted CLI — search Vinted from the terminal."""
@@ -0,0 +1,229 @@
1
+ """Vinted search API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+
8
+ import httpx
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ # Per-domain session cookie cache (lives for the duration of the process)
13
+ _session_cache: dict[str, httpx.Cookies] = {}
14
+
15
+ # Retry configuration
16
+ _RETRY_ATTEMPTS = 3
17
+ _RETRY_BACKOFF = 1.0 # seconds; doubles each attempt
18
+ _RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
19
+
20
+ # Supported country code → domain mapping
21
+ COUNTRIES: dict[str, str] = {
22
+ "se": "www.vinted.se",
23
+ "fr": "www.vinted.fr",
24
+ "de": "www.vinted.de",
25
+ "uk": "www.vinted.co.uk",
26
+ "pl": "www.vinted.pl",
27
+ "be": "www.vinted.be",
28
+ "nl": "www.vinted.nl",
29
+ "it": "www.vinted.it",
30
+ "es": "www.vinted.es",
31
+ "at": "www.vinted.at",
32
+ "lu": "www.vinted.lu",
33
+ "pt": "www.vinted.pt",
34
+ "cz": "www.vinted.cz",
35
+ "hu": "www.vinted.hu",
36
+ "ro": "www.vinted.ro",
37
+ "sk": "www.vinted.sk",
38
+ "lt": "www.vinted.lt",
39
+ "lv": "www.vinted.lv",
40
+ "ee": "www.vinted.ee",
41
+ }
42
+
43
+ # Vinted item condition status IDs
44
+ CONDITIONS: dict[str, str] = {
45
+ "new-with-tags": "6",
46
+ "new-without-tags": "1",
47
+ "very-good": "2",
48
+ "good": "3",
49
+ "satisfactory": "4",
50
+ }
51
+
52
+ CONDITION_LABELS: dict[str, str] = {
53
+ "new-with-tags": "New with tags",
54
+ "new-without-tags": "New without tags",
55
+ "very-good": "Very good",
56
+ "good": "Good",
57
+ "satisfactory": "Satisfactory",
58
+ }
59
+
60
+ # Sort order mapping
61
+ SORT_MAP: dict[str, str] = {
62
+ "relevance": "relevance",
63
+ "newest": "newest_first",
64
+ "oldest": "oldest_first",
65
+ "price-asc": "price_low_to_high",
66
+ "price-desc": "price_high_to_low",
67
+ }
68
+
69
+ HEADERS = {
70
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
71
+ "Accept": "application/json, text/plain, */*",
72
+ "Accept-Language": "sv-SE,sv;q=0.9,en-US;q=0.8,en;q=0.7",
73
+ }
74
+
75
+
76
+ def _base_url(domain: str) -> str:
77
+ return f"https://{domain}"
78
+
79
+
80
+ def _get_session(domain: str) -> httpx.Cookies:
81
+ """Return cached session cookies for *domain*, fetching them if not yet cached."""
82
+ if domain in _session_cache:
83
+ log.debug("Reusing cached session cookie for %s", domain)
84
+ return _session_cache[domain]
85
+
86
+ log.debug("Fetching session cookie from https://%s/", domain)
87
+ r = httpx.get(
88
+ f"https://{domain}/",
89
+ headers=HEADERS,
90
+ timeout=15,
91
+ follow_redirects=True,
92
+ )
93
+ log.debug("Session response: %s, cookies: %s", r.status_code, dict(r.cookies))
94
+ r.raise_for_status()
95
+ _session_cache[domain] = r.cookies
96
+ return r.cookies
97
+
98
+
99
+ def _request_with_retry(url: str, *, params=None, cookies: httpx.Cookies) -> httpx.Response:
100
+ """GET *url* with exponential-backoff retries on transient errors (429, 5xx)."""
101
+ delay = _RETRY_BACKOFF
102
+ for attempt in range(1, _RETRY_ATTEMPTS + 1):
103
+ r = httpx.get(url, params=params, headers=HEADERS, cookies=cookies, timeout=15, follow_redirects=True)
104
+ if r.status_code not in _RETRY_STATUS_CODES:
105
+ return r
106
+ if attempt == _RETRY_ATTEMPTS:
107
+ break
108
+ # Respect Retry-After header if present (429 often sends it)
109
+ retry_after = r.headers.get("Retry-After")
110
+ wait = float(retry_after) if retry_after and retry_after.isdigit() else delay
111
+ log.debug("HTTP %s — retrying in %.1fs (attempt %d/%d)", r.status_code, wait, attempt, _RETRY_ATTEMPTS)
112
+ time.sleep(wait)
113
+ delay *= 2
114
+ r.raise_for_status()
115
+ return r # unreachable after raise_for_status, satisfies type checkers
116
+
117
+
118
+ def _resolve_country(country: str) -> str:
119
+ domain = COUNTRIES.get(country.lower())
120
+ if not domain:
121
+ raise ValueError(f"Unknown country '{country}'. Valid: {', '.join(sorted(COUNTRIES))}")
122
+ return domain
123
+
124
+
125
+ def _resolve_condition(name: str) -> str:
126
+ code = CONDITIONS.get(name.lower())
127
+ if not code:
128
+ raise ValueError(f"Unknown condition '{name}'. Valid: {', '.join(sorted(CONDITIONS))}")
129
+ return code
130
+
131
+
132
+ def search(
133
+ query: str = "",
134
+ *,
135
+ country: str = "se",
136
+ price_min: int | None = None,
137
+ price_max: int | None = None,
138
+ condition: str | None = None,
139
+ brand_id: str | None = None,
140
+ size_id: str | None = None,
141
+ catalog_id: str | None = None,
142
+ sort: str | None = None,
143
+ per_page: int = 20,
144
+ page: int = 1,
145
+ ) -> dict:
146
+ """Search listings on Vinted.
147
+
148
+ brand_id, size_id, and catalog_id are numeric Vinted API IDs (e.g. brand_id="53" for Nike).
149
+ Include brand or size terms in the query text for free-text filtering instead.
150
+ """
151
+ domain = _resolve_country(country)
152
+ cookies = _get_session(domain)
153
+
154
+ params: list[tuple[str, str]] = [
155
+ ("per_page", str(per_page)),
156
+ ("page", str(page)),
157
+ ]
158
+ if query:
159
+ params.append(("search_text", query))
160
+ if price_min is not None:
161
+ params.append(("price_from", str(price_min)))
162
+ if price_max is not None:
163
+ params.append(("price_to", str(price_max)))
164
+ if condition:
165
+ params.append(("status_ids[]", _resolve_condition(condition)))
166
+ if brand_id:
167
+ params.append(("brand_ids[]", brand_id))
168
+ if size_id:
169
+ params.append(("size_ids[]", size_id))
170
+ if catalog_id:
171
+ params.append(("catalog_ids[]", catalog_id))
172
+ if sort:
173
+ params.append(("order", SORT_MAP.get(sort, sort)))
174
+
175
+ log.debug("GET https://%s/api/v2/catalog/items params=%s", domain, params)
176
+ r = _request_with_retry(f"https://{domain}/api/v2/catalog/items", params=params, cookies=cookies)
177
+ log.debug("Search response: %s", r.status_code)
178
+ log.debug("Response body: %s", r.text[:2000])
179
+ r.raise_for_status()
180
+ return r.json()
181
+
182
+
183
+ def get_item(item_id: int | str, *, country: str = "se") -> dict:
184
+ """Fetch full details for a specific item."""
185
+ domain = _resolve_country(country)
186
+ cookies = _get_session(domain)
187
+
188
+ log.debug("GET https://%s/api/v2/items/%s", domain, item_id)
189
+ r = _request_with_retry(f"https://{domain}/api/v2/items/{item_id}", cookies=cookies)
190
+ log.debug("Item response: %s", r.status_code)
191
+ log.debug("Response body: %s", r.text[:2000])
192
+ r.raise_for_status()
193
+ return r.json()
194
+
195
+
196
+ def fetch_catalogs(*, country: str = "se") -> list[dict]:
197
+ """Fetch the full catalog tree from the Vinted API."""
198
+ domain = _resolve_country(country)
199
+ cookies = _get_session(domain)
200
+
201
+ params = [("page", "1"), ("time", str(int(time.time())))]
202
+ log.debug("GET https://%s/api/v2/catalog/initializers", domain)
203
+ r = _request_with_retry(
204
+ f"https://{domain}/api/v2/catalog/initializers", params=params, cookies=cookies
205
+ )
206
+ r.raise_for_status()
207
+ data = r.json()
208
+ return data.get("dtos", {}).get("catalogs", [])
209
+
210
+
211
+ def _walk_catalogs(catalogs: list[dict], parent_id: int | None, depth: int) -> list[dict]:
212
+ """Recursively walk catalog tree, collecting all nodes under parent_id."""
213
+ results = []
214
+ for cat in catalogs:
215
+ if parent_id is None or cat.get("id") == parent_id:
216
+ # Found the root we care about — collect everything under it
217
+ results.append({"id": cat["id"], "title": cat["title"], "depth": depth})
218
+ for child in cat.get("catalogs") or []:
219
+ results.extend(_walk_catalogs([child], None, depth + 1))
220
+ else:
221
+ # Keep searching deeper
222
+ results.extend(_walk_catalogs(cat.get("catalogs") or [], parent_id, depth))
223
+ return results
224
+
225
+
226
+ def list_catalogs(*, country: str = "se", parent_id: int | None = None) -> list[dict]:
227
+ """Return a flat list of catalogs, optionally scoped to a parent catalog ID."""
228
+ catalogs = fetch_catalogs(country=country)
229
+ return _walk_catalogs(catalogs, parent_id, 0)
@@ -0,0 +1,181 @@
1
+ """Vinted CLI — search Vinted from the terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from . import api, format
12
+
13
+ DEFAULT_COUNTRY = os.environ.get("VINTED_COUNTRY", "se")
14
+
15
+ SORT_CHOICES = ["relevance", "newest", "oldest", "price-asc", "price-desc"]
16
+
17
+
18
+ @click.group()
19
+ @click.version_option()
20
+ @click.option("--debug", is_flag=True, envvar="VINTED_DEBUG", help="Enable debug logging.")
21
+ @click.pass_context
22
+ def main(ctx: click.Context, debug: bool):
23
+ """Search Vinted from the command line.
24
+
25
+ Fast, minimal CLI for searching Vinted. Defaults to vinted.se.
26
+ Designed for scripting, agents, and quick lookups.
27
+
28
+ Set the VINTED_COUNTRY environment variable to change the default country.
29
+ """
30
+ if debug:
31
+ logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s", stream=sys.stderr)
32
+
33
+
34
+ @main.command()
35
+ @click.argument("query", default="", required=False)
36
+ @click.option("--country", default=DEFAULT_COUNTRY, show_default=True, help="Country code (se, fr, de, uk, pl, …)")
37
+ @click.option("--price-min", type=int, help="Minimum price")
38
+ @click.option("--price-max", type=int, help="Maximum price")
39
+ @click.option(
40
+ "--condition",
41
+ type=click.Choice(["new-with-tags", "new-without-tags", "very-good", "good", "satisfactory"], case_sensitive=False),
42
+ help="Item condition",
43
+ )
44
+ @click.option("--brand-id", help="Numeric Vinted brand ID (e.g. 53 for Nike; include brand in query for free-text)")
45
+ @click.option("--size-id", help="Numeric Vinted size ID (include size in query for free-text, e.g. 'jeans XL')")
46
+ @click.option("--catalog-id", help="Numeric Vinted catalog ID to filter by category (use `vinted catalogs` to list them)")
47
+ @click.option("--sort", type=click.Choice(SORT_CHOICES, case_sensitive=False), help="Sort order")
48
+ @click.option("-n", "--limit", type=int, help="Max results to show")
49
+ @click.option("-p", "--page", type=int, default=1, help="Page number")
50
+ @click.option("-o", "--output", type=click.Choice(["table", "json", "jsonl"]), default="table", help="Output format")
51
+ @click.option("--raw", is_flag=True, help="Full API response (default: slim agent-friendly fields)")
52
+ def search(
53
+ query: str,
54
+ country: str,
55
+ price_min: int | None,
56
+ price_max: int | None,
57
+ condition: str | None,
58
+ brand_id: str | None,
59
+ size_id: str | None,
60
+ catalog_id: str | None,
61
+ sort: str | None,
62
+ limit: int | None,
63
+ page: int,
64
+ output: str,
65
+ raw: bool,
66
+ ):
67
+ """Search listings on Vinted.
68
+
69
+ QUERY is optional — omit it to browse without a text filter.
70
+
71
+ \b
72
+ Examples:
73
+ vinted search "jeans"
74
+ vinted search "iphone 15" --price-max 5000
75
+ vinted search "nike" --condition very-good --sort price-asc
76
+ vinted search "dress M" --sort newest -n 10
77
+ vinted search "jacka" -o json | jq '.results[:3]'
78
+ vinted search "jacka" --country se -o jsonl
79
+ vinted search "sneakers" --catalog-id 1231
80
+ vinted search --catalog-id 1231 --sort newest
81
+ vinted search --price-max 100
82
+ """
83
+ try:
84
+ data = api.search(
85
+ query,
86
+ country=country,
87
+ price_min=price_min,
88
+ price_max=price_max,
89
+ condition=condition,
90
+ brand_id=brand_id,
91
+ size_id=size_id,
92
+ catalog_id=catalog_id,
93
+ sort=sort,
94
+ page=page,
95
+ )
96
+ format.print_results(data, output=output, limit=limit, raw=raw)
97
+ except Exception as e:
98
+ click.echo(f"Error: {e}", err=True)
99
+ sys.exit(1)
100
+
101
+
102
+ @main.command()
103
+ @click.argument("item_id")
104
+ @click.option("--country", default=DEFAULT_COUNTRY, show_default=True, help="Country code (se, fr, de, uk, pl, …)")
105
+ @click.option("-o", "--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
106
+ def item(item_id: str, country: str, output: str):
107
+ """Get full details for a specific item.
108
+
109
+ \b
110
+ Examples:
111
+ vinted item 1234567890
112
+ vinted item 1234567890 -o json
113
+ vinted item 1234567890 --country fr
114
+ """
115
+ try:
116
+ data = api.get_item(item_id, country=country)
117
+ format.print_item(data, output=output)
118
+ except Exception as e:
119
+ click.echo(f"Error: {e}", err=True)
120
+ sys.exit(1)
121
+
122
+
123
+ @main.command()
124
+ def countries():
125
+ """List supported country codes.
126
+
127
+ \b
128
+ Examples:
129
+ vinted countries
130
+ """
131
+ print("Supported country codes:\n")
132
+ for code, domain in sorted(api.COUNTRIES.items()):
133
+ print(f" {code:<6} {domain}")
134
+
135
+
136
+ @main.command()
137
+ def conditions():
138
+ """List item condition filters.
139
+
140
+ \b
141
+ Examples:
142
+ vinted conditions
143
+ """
144
+ print("Item conditions:\n")
145
+ for key, label in api.CONDITION_LABELS.items():
146
+ print(f" {key:<20} {label}")
147
+
148
+
149
+ @main.command()
150
+ @click.option("--country", default=DEFAULT_COUNTRY, show_default=True, help="Country code")
151
+ @click.option("--parent-id", type=int, default=None, help="Show only subcatalogs of this catalog ID")
152
+ @click.option("-o", "--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
153
+ def catalogs(country: str, parent_id: int | None, output: str):
154
+ """List Vinted catalog IDs and their names.
155
+
156
+ \b
157
+ Examples:
158
+ vinted catalogs
159
+ vinted catalogs --parent-id 2994
160
+ vinted catalogs --parent-id 2994 -o json
161
+ vinted catalogs --country fr --parent-id 2994
162
+ """
163
+ try:
164
+ entries = api.list_catalogs(country=country, parent_id=parent_id)
165
+ if output == "json":
166
+ import json
167
+ print(json.dumps(entries, ensure_ascii=False))
168
+ return
169
+ if not entries:
170
+ click.echo("No catalogs found.", err=True)
171
+ return
172
+ for entry in entries:
173
+ indent = " " * entry["depth"]
174
+ print(f"{indent}{entry['id']:<8} {entry['title']}")
175
+ except Exception as e:
176
+ click.echo(f"Error: {e}", err=True)
177
+ sys.exit(1)
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()
@@ -0,0 +1,126 @@
1
+ """Output formatting for Vinted CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+ MAX_DESCRIPTION_LENGTH = 200
10
+
11
+
12
+ def _json_compact(data: Any) -> str:
13
+ return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
14
+
15
+
16
+ def _extract_price(item: dict) -> tuple[str, str]:
17
+ """Return (amount, currency) handling both flat and nested price formats."""
18
+ price = item.get("price")
19
+ if isinstance(price, dict):
20
+ return price.get("amount", "—"), price.get("currency_code", "")
21
+ # Legacy flat format
22
+ return str(price) if price else "—", item.get("currency", "")
23
+
24
+
25
+ def _slim(item: dict) -> dict:
26
+ """Strip an item to agent-essential fields."""
27
+ amount, currency = _extract_price(item)
28
+ out: dict[str, Any] = {
29
+ "id": item.get("id"),
30
+ "title": item.get("title"),
31
+ "price": amount,
32
+ "currency": currency,
33
+ "brand": item.get("brand_title"),
34
+ "size": item.get("size_title"),
35
+ "condition": item.get("status"),
36
+ "seller": item.get("user", {}).get("login"),
37
+ "url": item.get("url"),
38
+ }
39
+ photo = item.get("photo")
40
+ if isinstance(photo, dict):
41
+ out["photo"] = photo.get("url") or photo.get("full_size_url")
42
+ # Remove None values for cleaner output
43
+ return {k: v for k, v in out.items() if v is not None}
44
+
45
+
46
+ def print_results(data: dict, *, output: str = "table", limit: int | None = None, raw: bool = False) -> None:
47
+ """Print search results."""
48
+ items = data.get("items", [])
49
+ total = data.get("pagination", {}).get("total_count", len(items))
50
+
51
+ if limit:
52
+ items = items[:limit]
53
+
54
+ if output == "json":
55
+ results = items if raw else [_slim(i) for i in items]
56
+ print(_json_compact({"total": total, "results": results}))
57
+ return
58
+
59
+ if output == "jsonl":
60
+ for item in items:
61
+ print(_json_compact(item if raw else _slim(item)))
62
+ return
63
+
64
+ if not items:
65
+ print("No results found.", file=sys.stderr)
66
+ return
67
+
68
+ print(f"Found {total:,} listings (showing {len(items)}):\n")
69
+
70
+ for item in items:
71
+ title = item.get("title", "Untitled")
72
+ price, currency = _extract_price(item)
73
+ brand = item.get("brand_title", "")
74
+ size = item.get("size_title", "")
75
+ condition = item.get("status", "")
76
+ seller = item.get("user", {}).get("login", "")
77
+ url = item.get("url", "")
78
+
79
+ price_str = f"{price} {currency}".strip() if price else "—"
80
+ meta_parts = [p for p in [brand, size, condition] if p]
81
+ meta_str = " · ".join(meta_parts) if meta_parts else ""
82
+
83
+ print(f" {title}")
84
+ print(f" {price_str}" + (f" · {meta_str}" if meta_str else ""))
85
+ if seller:
86
+ print(f" Seller: {seller}")
87
+ print(f" {url}")
88
+ print()
89
+
90
+
91
+ def print_item(data: dict, *, output: str = "table") -> None:
92
+ """Print item details."""
93
+ if output == "json":
94
+ print(_json_compact(data))
95
+ return
96
+
97
+ item = data.get("item", data)
98
+
99
+ if "error" in item:
100
+ print(f"Error: {item['error']}", file=sys.stderr)
101
+ return
102
+
103
+ title = item.get("title", "Untitled")
104
+ price, currency = _extract_price(item)
105
+ brand = item.get("brand_title", "")
106
+ size = item.get("size_title", "")
107
+ condition = item.get("status", "")
108
+ description = item.get("description", "")
109
+ seller = item.get("user", {}).get("login", "")
110
+ url = item.get("url", "")
111
+
112
+ price_str = f"{price} {currency}".strip()
113
+ print(f" {title}")
114
+ print(f" {price_str}")
115
+ if brand:
116
+ print(f" Brand: {brand}")
117
+ if size:
118
+ print(f" Size: {size}")
119
+ if condition:
120
+ print(f" Condition: {condition}")
121
+ if seller:
122
+ print(f" Seller: {seller}")
123
+ if description:
124
+ print(f" Description: {description[:MAX_DESCRIPTION_LENGTH]}")
125
+ if url:
126
+ print(f" {url}")