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.
- vinted_cli-0.1.0/.github/workflows/publish.yml +34 -0
- vinted_cli-0.1.0/.gitignore +8 -0
- vinted_cli-0.1.0/LICENSE +21 -0
- vinted_cli-0.1.0/PKG-INFO +147 -0
- vinted_cli-0.1.0/README.md +122 -0
- vinted_cli-0.1.0/pyproject.toml +46 -0
- vinted_cli-0.1.0/tests/test_cli.py +175 -0
- vinted_cli-0.1.0/vinted_cli/__init__.py +1 -0
- vinted_cli-0.1.0/vinted_cli/api.py +229 -0
- vinted_cli-0.1.0/vinted_cli/cli.py +181 -0
- vinted_cli-0.1.0/vinted_cli/format.py +126 -0
|
@@ -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
|
vinted_cli-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://python.org)
|
|
29
|
+
[](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
|
+
[](https://python.org)
|
|
4
|
+
[](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}")
|