allegro-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.
- allegro_cli-0.1.0/PKG-INFO +152 -0
- allegro_cli-0.1.0/README.md +135 -0
- allegro_cli-0.1.0/allegro_cli/__init__.py +1 -0
- allegro_cli-0.1.0/allegro_cli/api/__init__.py +0 -0
- allegro_cli-0.1.0/allegro_cli/api/client.py +332 -0
- allegro_cli-0.1.0/allegro_cli/api/models.py +88 -0
- allegro_cli-0.1.0/allegro_cli/commands/__init__.py +0 -0
- allegro_cli-0.1.0/allegro_cli/commands/cart.py +72 -0
- allegro_cli-0.1.0/allegro_cli/commands/config_cmd.py +40 -0
- allegro_cli-0.1.0/allegro_cli/commands/login.py +44 -0
- allegro_cli-0.1.0/allegro_cli/commands/packages.py +25 -0
- allegro_cli-0.1.0/allegro_cli/commands/search.py +51 -0
- allegro_cli-0.1.0/allegro_cli/config.py +42 -0
- allegro_cli-0.1.0/allegro_cli/cookie_import.py +24 -0
- allegro_cli-0.1.0/allegro_cli/main.py +207 -0
- allegro_cli-0.1.0/allegro_cli/output.py +90 -0
- allegro_cli-0.1.0/allegro_cli/scraper.py +572 -0
- allegro_cli-0.1.0/allegro_cli.egg-info/PKG-INFO +152 -0
- allegro_cli-0.1.0/allegro_cli.egg-info/SOURCES.txt +27 -0
- allegro_cli-0.1.0/allegro_cli.egg-info/dependency_links.txt +1 -0
- allegro_cli-0.1.0/allegro_cli.egg-info/entry_points.txt +2 -0
- allegro_cli-0.1.0/allegro_cli.egg-info/requires.txt +9 -0
- allegro_cli-0.1.0/allegro_cli.egg-info/top_level.txt +1 -0
- allegro_cli-0.1.0/pyproject.toml +41 -0
- allegro_cli-0.1.0/setup.cfg +4 -0
- allegro_cli-0.1.0/tests/test_cli.py +225 -0
- allegro_cli-0.1.0/tests/test_config.py +32 -0
- allegro_cli-0.1.0/tests/test_e2e.py +356 -0
- allegro_cli-0.1.0/tests/test_scraper.py +473 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: allegro-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for browsing Allegro offers, managing cart, and tracking packages — human-readable and LLM-agent friendly
|
|
5
|
+
Author: Piotr Konowrocki
|
|
6
|
+
Project-URL: Repository, https://github.com/pkonowrocki/allegro-cli
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
11
|
+
Requires-Dist: lxml>=5.0
|
|
12
|
+
Requires-Dist: curl_cffi>=0.7
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: commitizen>=4.1; extra == "dev"
|
|
16
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
17
|
+
|
|
18
|
+
# allegro-cli
|
|
19
|
+
|
|
20
|
+
[](https://github.com/pkonowrocki/allegro-cli/actions/workflows/ci.yml)
|
|
21
|
+
|
|
22
|
+
CLI for browsing [Allegro](https://allegro.pl) offers, managing your cart, and tracking packages. Designed to be both human-readable and LLM-agent friendly.
|
|
23
|
+
|
|
24
|
+
All output is available as aligned text tables, JSON, or TSV — pick what suits your workflow or pipe it into other tools.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
**From GitHub Releases** (recommended):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install https://github.com/pkonowrocki/allegro-cli/releases/latest/download/allegro_cli-0.1.0-py3-none-any.whl
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**From source (latest)**:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install git+https://github.com/pkonowrocki/allegro-cli.git
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**For development**:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/pkonowrocki/allegro-cli.git
|
|
44
|
+
cd allegro-cli
|
|
45
|
+
pip install -e ".[dev]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Setup
|
|
49
|
+
|
|
50
|
+
Import cookies from your browser:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
allegro login
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Paste cookies from Chrome DevTools (Application > Cookies > allegro.pl). Both the DevTools table format and raw cookie header strings are accepted.
|
|
57
|
+
|
|
58
|
+
Alternatively, set cookies directly:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
allegro config set --cookies 'cookie1=value1; cookie2=value2'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### Search
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
allegro search "laptop"
|
|
70
|
+
allegro search "laptop" --category 491
|
|
71
|
+
allegro search "laptop" --category laptopy-491 --sort pd --price-min 2000 --price-max 5000
|
|
72
|
+
allegro search "laptop" --columns "id,name,sellingMode.price.amount,parameters"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Flag | Description |
|
|
76
|
+
|------|-------------|
|
|
77
|
+
| `--category` | Category ID or slug (e.g. `491`, `laptopy-491`) |
|
|
78
|
+
| `--sort` | Sort order: `p` (price asc), `pd` (price desc), `m` (relevance), `n` (newest) |
|
|
79
|
+
| `--price-min` | Minimum price in PLN |
|
|
80
|
+
| `--price-max` | Maximum price in PLN |
|
|
81
|
+
| `--page` | Page number (default: 1) |
|
|
82
|
+
| `--columns` | Comma-separated columns to display |
|
|
83
|
+
|
|
84
|
+
### Offer details
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
allegro offer 12345678
|
|
88
|
+
allegro offer 12345678 --columns "name,sellingMode.price.amount,parameters"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Offer pages include a `parameters` field with product specifications (e.g. processor, RAM, screen size) extracted automatically from the listing.
|
|
92
|
+
|
|
93
|
+
### Cart
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
allegro cart list
|
|
97
|
+
allegro cart add OFFER_ID SELLER_ID --quantity 2
|
|
98
|
+
allegro cart remove OFFER_ID SELLER_ID
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Packages
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
allegro packages
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Configuration
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
allegro config show
|
|
111
|
+
allegro config set --output-format json
|
|
112
|
+
allegro config set --flaresolverr-url http://localhost:8191/v1
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Output formats
|
|
116
|
+
|
|
117
|
+
All commands support `--format text` (default), `--format json`, and `--format tsv`.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
allegro search "laptop" --format json # full JSON array
|
|
121
|
+
allegro search "laptop" --format tsv # tab-separated, pipe-friendly
|
|
122
|
+
allegro search "laptop" # aligned text table (default)
|
|
123
|
+
allegro offer 12345678 --format json # full offer with parameters
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use `--columns` to select specific fields (dot-notation supported):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
allegro search "laptop" --columns "id,name,sellingMode.price.amount"
|
|
130
|
+
allegro offer 12345678 --columns "name,parameters"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Set a persistent default:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
allegro config set --output-format json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Anti-bot handling
|
|
140
|
+
|
|
141
|
+
Allegro uses anti-bot protection (DataDome). The CLI first tries a direct request with your cookies via `curl_cffi` (Chrome TLS fingerprint). If that gets a 403, it falls back to [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr):
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install -e ".[dev]"
|
|
151
|
+
pytest
|
|
152
|
+
```
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# allegro-cli
|
|
2
|
+
|
|
3
|
+
[](https://github.com/pkonowrocki/allegro-cli/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
CLI for browsing [Allegro](https://allegro.pl) offers, managing your cart, and tracking packages. Designed to be both human-readable and LLM-agent friendly.
|
|
6
|
+
|
|
7
|
+
All output is available as aligned text tables, JSON, or TSV — pick what suits your workflow or pipe it into other tools.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
**From GitHub Releases** (recommended):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install https://github.com/pkonowrocki/allegro-cli/releases/latest/download/allegro_cli-0.1.0-py3-none-any.whl
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**From source (latest)**:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install git+https://github.com/pkonowrocki/allegro-cli.git
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**For development**:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/pkonowrocki/allegro-cli.git
|
|
27
|
+
cd allegro-cli
|
|
28
|
+
pip install -e ".[dev]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
Import cookies from your browser:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
allegro login
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Paste cookies from Chrome DevTools (Application > Cookies > allegro.pl). Both the DevTools table format and raw cookie header strings are accepted.
|
|
40
|
+
|
|
41
|
+
Alternatively, set cookies directly:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
allegro config set --cookies 'cookie1=value1; cookie2=value2'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### Search
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
allegro search "laptop"
|
|
53
|
+
allegro search "laptop" --category 491
|
|
54
|
+
allegro search "laptop" --category laptopy-491 --sort pd --price-min 2000 --price-max 5000
|
|
55
|
+
allegro search "laptop" --columns "id,name,sellingMode.price.amount,parameters"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Flag | Description |
|
|
59
|
+
|------|-------------|
|
|
60
|
+
| `--category` | Category ID or slug (e.g. `491`, `laptopy-491`) |
|
|
61
|
+
| `--sort` | Sort order: `p` (price asc), `pd` (price desc), `m` (relevance), `n` (newest) |
|
|
62
|
+
| `--price-min` | Minimum price in PLN |
|
|
63
|
+
| `--price-max` | Maximum price in PLN |
|
|
64
|
+
| `--page` | Page number (default: 1) |
|
|
65
|
+
| `--columns` | Comma-separated columns to display |
|
|
66
|
+
|
|
67
|
+
### Offer details
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
allegro offer 12345678
|
|
71
|
+
allegro offer 12345678 --columns "name,sellingMode.price.amount,parameters"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Offer pages include a `parameters` field with product specifications (e.g. processor, RAM, screen size) extracted automatically from the listing.
|
|
75
|
+
|
|
76
|
+
### Cart
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
allegro cart list
|
|
80
|
+
allegro cart add OFFER_ID SELLER_ID --quantity 2
|
|
81
|
+
allegro cart remove OFFER_ID SELLER_ID
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Packages
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
allegro packages
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Configuration
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
allegro config show
|
|
94
|
+
allegro config set --output-format json
|
|
95
|
+
allegro config set --flaresolverr-url http://localhost:8191/v1
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Output formats
|
|
99
|
+
|
|
100
|
+
All commands support `--format text` (default), `--format json`, and `--format tsv`.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
allegro search "laptop" --format json # full JSON array
|
|
104
|
+
allegro search "laptop" --format tsv # tab-separated, pipe-friendly
|
|
105
|
+
allegro search "laptop" # aligned text table (default)
|
|
106
|
+
allegro offer 12345678 --format json # full offer with parameters
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Use `--columns` to select specific fields (dot-notation supported):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
allegro search "laptop" --columns "id,name,sellingMode.price.amount"
|
|
113
|
+
allegro offer 12345678 --columns "name,parameters"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Set a persistent default:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
allegro config set --output-format json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Anti-bot handling
|
|
123
|
+
|
|
124
|
+
Allegro uses anti-bot protection (DataDome). The CLI first tries a direct request with your cookies via `curl_cffi` (Chrome TLS fingerprint). If that gets a 403, it falls back to [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr):
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pip install -e ".[dev]"
|
|
134
|
+
pytest
|
|
135
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from curl_cffi.requests import Session as CffiSession
|
|
9
|
+
|
|
10
|
+
from allegro_cli.api.models import (
|
|
11
|
+
AllegroCliError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
Offer,
|
|
14
|
+
)
|
|
15
|
+
from allegro_cli.config import Config
|
|
16
|
+
|
|
17
|
+
_COMMON_HEADERS = {
|
|
18
|
+
"origin": "https://allegro.pl",
|
|
19
|
+
"referer": "https://allegro.pl/",
|
|
20
|
+
"sec-ch-ua-mobile": "?0",
|
|
21
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
22
|
+
"sec-fetch-dest": "empty",
|
|
23
|
+
"sec-fetch-mode": "cors",
|
|
24
|
+
"sec-fetch-site": "same-site",
|
|
25
|
+
"accept-language": "pl-PL",
|
|
26
|
+
"user-agent": (
|
|
27
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
28
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
29
|
+
"Chrome/144.0.0.0 Safari/537.36"
|
|
30
|
+
),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AllegroClient:
|
|
35
|
+
def __init__(self, config: Config, verbose: bool = False):
|
|
36
|
+
self._config = config
|
|
37
|
+
self._verbose = verbose
|
|
38
|
+
|
|
39
|
+
# Edge client for cart/packages — only when cookies are present
|
|
40
|
+
self._edge: httpx.Client | None = None
|
|
41
|
+
self._web: CffiSession | None = None
|
|
42
|
+
if config.cookies:
|
|
43
|
+
self._edge = httpx.Client(
|
|
44
|
+
base_url=config.edgeBaseUrl,
|
|
45
|
+
headers={**_COMMON_HEADERS, "cookie": config.cookies},
|
|
46
|
+
timeout=30.0,
|
|
47
|
+
)
|
|
48
|
+
# curl_cffi session — impersonates Chrome TLS fingerprint to pass Cloudflare
|
|
49
|
+
self._web = CffiSession(impersonate="chrome")
|
|
50
|
+
self._web.headers.update({"cookie": config.cookies})
|
|
51
|
+
|
|
52
|
+
# --- Scrape (allegro.pl, cookie auth) ---
|
|
53
|
+
|
|
54
|
+
def scrape_search(
|
|
55
|
+
self,
|
|
56
|
+
phrase: str,
|
|
57
|
+
page: int = 1,
|
|
58
|
+
category: str | None = None,
|
|
59
|
+
sort: str | None = None,
|
|
60
|
+
price_min: str | None = None,
|
|
61
|
+
price_max: str | None = None,
|
|
62
|
+
) -> list[Offer]:
|
|
63
|
+
if not self._config.cookies:
|
|
64
|
+
raise AuthenticationError(
|
|
65
|
+
"No cookies configured. Scrape requires browser cookies.\n"
|
|
66
|
+
"Run: allegro login"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
from allegro_cli.scraper import parse_search_results
|
|
70
|
+
|
|
71
|
+
if category:
|
|
72
|
+
cat_match = re.search(r"(\d+)$", category)
|
|
73
|
+
if cat_match:
|
|
74
|
+
base_url = f"https://allegro.pl/kategoria/-{cat_match.group(1)}"
|
|
75
|
+
else:
|
|
76
|
+
base_url = f"https://allegro.pl/kategoria/{category}"
|
|
77
|
+
else:
|
|
78
|
+
base_url = "https://allegro.pl/listing"
|
|
79
|
+
|
|
80
|
+
params: dict[str, str] = {"string": phrase}
|
|
81
|
+
if page > 1:
|
|
82
|
+
params["p"] = str(page)
|
|
83
|
+
if sort:
|
|
84
|
+
params["order"] = sort
|
|
85
|
+
if price_min:
|
|
86
|
+
params["price_from"] = price_min
|
|
87
|
+
if price_max:
|
|
88
|
+
params["price_to"] = price_max
|
|
89
|
+
|
|
90
|
+
full_url = base_url + "?" + urlencode(params)
|
|
91
|
+
|
|
92
|
+
html = self._fetch_page(full_url)
|
|
93
|
+
return parse_search_results(html)
|
|
94
|
+
|
|
95
|
+
def scrape_offer(self, offer_id: str) -> Offer:
|
|
96
|
+
"""Fetch and parse a single offer page by ID."""
|
|
97
|
+
if not self._config.cookies:
|
|
98
|
+
raise AuthenticationError(
|
|
99
|
+
"No cookies configured. Scrape requires browser cookies.\n"
|
|
100
|
+
"Run: allegro login"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
from allegro_cli.scraper import (
|
|
104
|
+
extract_lazy_contexts,
|
|
105
|
+
parse_offer_page,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
url = f"https://allegro.pl/oferta/-{offer_id}"
|
|
109
|
+
html = self._fetch_page(url)
|
|
110
|
+
offer = parse_offer_page(html, offer_id=offer_id)
|
|
111
|
+
|
|
112
|
+
# If we only got a few params, try lazy loading the rest
|
|
113
|
+
if len(offer.parameters) < 15:
|
|
114
|
+
contexts = extract_lazy_contexts(html)
|
|
115
|
+
if contexts:
|
|
116
|
+
lazy_params = self._fetch_lazy_parameters(url, contexts)
|
|
117
|
+
for k, v in lazy_params.items():
|
|
118
|
+
offer.parameters.setdefault(k, v)
|
|
119
|
+
|
|
120
|
+
return offer
|
|
121
|
+
|
|
122
|
+
def _fetch_lazy_parameters(
|
|
123
|
+
self, offer_url: str, contexts: list[dict],
|
|
124
|
+
) -> dict[str, str]:
|
|
125
|
+
"""Fetch lazy-loaded parameter groups via the opbox API."""
|
|
126
|
+
from allegro_cli.scraper import parse_opbox_parameters
|
|
127
|
+
|
|
128
|
+
result: dict[str, str] = {}
|
|
129
|
+
max_requests = 3
|
|
130
|
+
for ctx in contexts[:max_requests]:
|
|
131
|
+
lazy_url = f"{offer_url}?lazyContext={ctx['value']}"
|
|
132
|
+
self._log(f"GET {lazy_url} (lazy params)")
|
|
133
|
+
try:
|
|
134
|
+
resp = self._web.get(
|
|
135
|
+
lazy_url,
|
|
136
|
+
headers={
|
|
137
|
+
"Accept": "application/vnd.opbox-web.subtree+json",
|
|
138
|
+
},
|
|
139
|
+
timeout=15,
|
|
140
|
+
)
|
|
141
|
+
except Exception:
|
|
142
|
+
continue
|
|
143
|
+
if resp.status_code != 200:
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
data = resp.json()
|
|
147
|
+
except (ValueError, Exception):
|
|
148
|
+
continue
|
|
149
|
+
params = parse_opbox_parameters(data)
|
|
150
|
+
for k, v in params.items():
|
|
151
|
+
result.setdefault(k, v)
|
|
152
|
+
if len(result) > 15:
|
|
153
|
+
break
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
def _fetch_page(self, url: str) -> str:
|
|
157
|
+
# Try direct curl_cffi first
|
|
158
|
+
if self._web:
|
|
159
|
+
self._log(f"GET {url} (direct)")
|
|
160
|
+
t0 = time.monotonic()
|
|
161
|
+
resp = self._web.get(url, timeout=30)
|
|
162
|
+
elapsed = time.monotonic() - t0
|
|
163
|
+
self._log(f"Response: {resp.status_code} ({elapsed:.1f}s)")
|
|
164
|
+
|
|
165
|
+
if resp.status_code == 200:
|
|
166
|
+
return resp.text
|
|
167
|
+
|
|
168
|
+
if resp.status_code == 401:
|
|
169
|
+
raise AuthenticationError("Session expired (401). Run: allegro login")
|
|
170
|
+
|
|
171
|
+
# 403 = DataDome challenge — fall through to FlareSolverr
|
|
172
|
+
if resp.status_code != 403:
|
|
173
|
+
raise AllegroCliError(
|
|
174
|
+
message=f"Scrape returned {resp.status_code}: {resp.text[:300]}",
|
|
175
|
+
code="ScrapeException",
|
|
176
|
+
userMessage=f"Could not fetch search page ({resp.status_code}).",
|
|
177
|
+
)
|
|
178
|
+
self._log("Direct fetch got 403 (DataDome), trying FlareSolverr...")
|
|
179
|
+
|
|
180
|
+
# Fall back to FlareSolverr
|
|
181
|
+
return self._fetch_via_flaresolverr(url)
|
|
182
|
+
|
|
183
|
+
def _fetch_via_flaresolverr(self, url: str) -> str:
|
|
184
|
+
fs_url = self._config.flareSolverrUrl
|
|
185
|
+
if not fs_url:
|
|
186
|
+
# Auto-detect on default port
|
|
187
|
+
fs_url = "http://localhost:8191/v1"
|
|
188
|
+
|
|
189
|
+
self._log(f"FlareSolverr POST {fs_url}")
|
|
190
|
+
t0 = time.monotonic()
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
resp = httpx.post(
|
|
194
|
+
fs_url,
|
|
195
|
+
json={"cmd": "request.get", "url": url, "maxTimeout": 60000},
|
|
196
|
+
timeout=90.0,
|
|
197
|
+
)
|
|
198
|
+
except httpx.ConnectError:
|
|
199
|
+
raise AllegroCliError(
|
|
200
|
+
message=f"Cannot connect to FlareSolverr at {fs_url}",
|
|
201
|
+
code="FlareSolverrUnavailable",
|
|
202
|
+
userMessage=(
|
|
203
|
+
"Direct fetch blocked by anti-bot (403) and FlareSolverr "
|
|
204
|
+
"is not running.\n"
|
|
205
|
+
"Start it with:\n"
|
|
206
|
+
" docker run -d --name flaresolverr -p 8191:8191 "
|
|
207
|
+
"ghcr.io/flaresolverr/flaresolverr:latest\n"
|
|
208
|
+
"Or refresh your cookies:\n"
|
|
209
|
+
" allegro login"
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
elapsed = time.monotonic() - t0
|
|
214
|
+
self._log(f"FlareSolverr response: {resp.status_code} ({elapsed:.1f}s)")
|
|
215
|
+
|
|
216
|
+
if resp.status_code != 200:
|
|
217
|
+
raise AllegroCliError(
|
|
218
|
+
message=f"FlareSolverr returned {resp.status_code}: {resp.text[:300]}",
|
|
219
|
+
code="FlareSolverrError",
|
|
220
|
+
userMessage="FlareSolverr returned an error.",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
data = resp.json()
|
|
224
|
+
if data.get("status") != "ok":
|
|
225
|
+
raise AllegroCliError(
|
|
226
|
+
message=f"FlareSolverr error: {data.get('message', 'unknown')}",
|
|
227
|
+
code="FlareSolverrError",
|
|
228
|
+
userMessage=f"FlareSolverr: {data.get('message', 'unknown error')}",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
solution = data.get("solution", {})
|
|
232
|
+
sol_status = solution.get("status", 0)
|
|
233
|
+
if sol_status >= 400:
|
|
234
|
+
raise AllegroCliError(
|
|
235
|
+
message=f"FlareSolverr got {sol_status} from target",
|
|
236
|
+
code="ScrapeException",
|
|
237
|
+
userMessage=f"Could not fetch search page ({sol_status}).",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return solution.get("response", "")
|
|
241
|
+
|
|
242
|
+
# --- Cart (edge.allegro.pl, cookie auth) ---
|
|
243
|
+
|
|
244
|
+
def _require_edge(self) -> httpx.Client:
|
|
245
|
+
if not self._edge:
|
|
246
|
+
raise AuthenticationError(
|
|
247
|
+
"No cookies configured. Cart/packages require browser cookies.\n"
|
|
248
|
+
"Run: allegro login"
|
|
249
|
+
)
|
|
250
|
+
return self._edge
|
|
251
|
+
|
|
252
|
+
def get_cart(self) -> dict:
|
|
253
|
+
resp = self._request(
|
|
254
|
+
"GET", "/carts",
|
|
255
|
+
accept="application/vnd.allegro.internal.v6+json",
|
|
256
|
+
)
|
|
257
|
+
return resp.json()
|
|
258
|
+
|
|
259
|
+
def change_cart_quantity(
|
|
260
|
+
self,
|
|
261
|
+
item_id: str,
|
|
262
|
+
delta: int,
|
|
263
|
+
seller_id: str,
|
|
264
|
+
nav_category_id: str | None = None,
|
|
265
|
+
) -> None:
|
|
266
|
+
body = {
|
|
267
|
+
"items": [
|
|
268
|
+
{
|
|
269
|
+
"itemId": item_id,
|
|
270
|
+
"delta": delta,
|
|
271
|
+
"sellerId": seller_id,
|
|
272
|
+
**({"navCategoryId": nav_category_id} if nav_category_id else {}),
|
|
273
|
+
"navTree": "navigation-pl",
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
self._request(
|
|
278
|
+
"POST",
|
|
279
|
+
"/carts/changeQuantityCommand",
|
|
280
|
+
json=body,
|
|
281
|
+
accept="application/vnd.allegro.public.v5+json",
|
|
282
|
+
content_type="application/vnd.allegro.public.v5+json",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# --- Packages / delivery ---
|
|
286
|
+
|
|
287
|
+
def get_packages_summary(self) -> dict:
|
|
288
|
+
resp = self._request(
|
|
289
|
+
"GET", "/packages/summary",
|
|
290
|
+
accept="application/vnd.allegro.internal.v1+json",
|
|
291
|
+
)
|
|
292
|
+
return resp.json()
|
|
293
|
+
|
|
294
|
+
# --- HTTP layer (edge API, cookie auth) ---
|
|
295
|
+
|
|
296
|
+
def _request(
|
|
297
|
+
self,
|
|
298
|
+
method: str,
|
|
299
|
+
path: str,
|
|
300
|
+
accept: str = "application/vnd.allegro.internal.v1+json",
|
|
301
|
+
content_type: str | None = None,
|
|
302
|
+
**kwargs,
|
|
303
|
+
) -> httpx.Response:
|
|
304
|
+
edge = self._require_edge()
|
|
305
|
+
headers = {"accept": accept}
|
|
306
|
+
if content_type:
|
|
307
|
+
headers["content-type"] = content_type
|
|
308
|
+
|
|
309
|
+
resp = edge.request(method, path, headers=headers, **kwargs)
|
|
310
|
+
|
|
311
|
+
if resp.status_code == 401:
|
|
312
|
+
raise AuthenticationError(
|
|
313
|
+
"Session expired (401). Run: allegro login"
|
|
314
|
+
)
|
|
315
|
+
if resp.status_code == 403:
|
|
316
|
+
raise AllegroCliError(
|
|
317
|
+
message="Forbidden (403)",
|
|
318
|
+
code="ForbiddenException",
|
|
319
|
+
userMessage="Access denied. Your session cookies may have expired.",
|
|
320
|
+
)
|
|
321
|
+
if resp.status_code >= 400 and resp.status_code != 204:
|
|
322
|
+
raise AllegroCliError(
|
|
323
|
+
message=f"API returned {resp.status_code}: {resp.text[:300]}",
|
|
324
|
+
code="ApiException",
|
|
325
|
+
userMessage=f"Allegro API error ({resp.status_code}).",
|
|
326
|
+
)
|
|
327
|
+
return resp
|
|
328
|
+
|
|
329
|
+
def _log(self, msg: str) -> None:
|
|
330
|
+
if self._verbose:
|
|
331
|
+
import sys
|
|
332
|
+
print(msg, file=sys.stderr, flush=True)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# --- Data models (Allegro REST API conventions) ---
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Price:
|
|
10
|
+
amount: str
|
|
11
|
+
currency: str = "PLN"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Seller:
|
|
16
|
+
id: str
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Category:
|
|
22
|
+
id: str
|
|
23
|
+
name: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Image:
|
|
28
|
+
url: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SellingMode:
|
|
33
|
+
format: str # BUY_NOW, AUCTION, ADVERTISEMENT
|
|
34
|
+
price: Price
|
|
35
|
+
popularity: int | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class DeliveryInfo:
|
|
40
|
+
lowestPrice: Price | None = None
|
|
41
|
+
availableForFree: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Stock:
|
|
46
|
+
unit: str = "UNIT"
|
|
47
|
+
available: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Offer:
|
|
52
|
+
id: str
|
|
53
|
+
name: str
|
|
54
|
+
seller: Seller
|
|
55
|
+
sellingMode: SellingMode
|
|
56
|
+
category: Category
|
|
57
|
+
images: list[Image] = field(default_factory=list)
|
|
58
|
+
delivery: DeliveryInfo | None = None
|
|
59
|
+
stock: Stock | None = None
|
|
60
|
+
parameters: dict[str, str] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- Exceptions ---
|
|
64
|
+
|
|
65
|
+
class AllegroCliError(Exception):
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str,
|
|
69
|
+
code: str,
|
|
70
|
+
path: str | None = None,
|
|
71
|
+
userMessage: str | None = None,
|
|
72
|
+
):
|
|
73
|
+
self.message = message
|
|
74
|
+
self.code = code
|
|
75
|
+
self.path = path
|
|
76
|
+
self.userMessage = userMessage or message
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AuthenticationError(AllegroCliError):
|
|
81
|
+
def __init__(self, message: str = "Authentication failed"):
|
|
82
|
+
super().__init__(
|
|
83
|
+
message=message,
|
|
84
|
+
code="AuthenticationException",
|
|
85
|
+
userMessage="Could not authenticate. Check your client-id and client-secret.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|