walla-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- walla/__init__.py +7 -0
- walla/account/__init__.py +23 -0
- walla/account/actions.py +135 -0
- walla/account/cookies.py +88 -0
- walla/account/inbox.py +147 -0
- walla/account/login.py +205 -0
- walla/account/prompt.py +56 -0
- walla/account/session_store.py +36 -0
- walla/cli/__init__.py +7 -0
- walla/cli/banner.py +33 -0
- walla/cli/catch.py +43 -0
- walla/cli/cmd_account.py +162 -0
- walla/cli/cmd_look.py +143 -0
- walla/cli/cmd_ops.py +100 -0
- walla/cli/console.py +9 -0
- walla/cli/main.py +44 -0
- walla/core/__init__.py +12 -0
- walla/core/envelope.py +46 -0
- walla/core/errors.py +90 -0
- walla/core/exceptions.py +67 -0
- walla/core/human_fix.py +26 -0
- walla/core/instruct.py +50 -0
- walla/core/path.py +32 -0
- walla/http/__init__.py +14 -0
- walla/http/client.py +169 -0
- walla/http/headers.py +36 -0
- walla/http/polite.py +108 -0
- walla/hunter/__init__.py +18 -0
- walla/hunter/profile_store.py +56 -0
- walla/hunter/verdict.py +42 -0
- walla/hunter/watches.py +60 -0
- walla/mcp/__init__.py +7 -0
- walla/mcp/_entry.py +17 -0
- walla/mcp/server.py +135 -0
- walla/models/__init__.py +18 -0
- walla/models/account.py +48 -0
- walla/models/listing.py +51 -0
- walla/models/profile.py +37 -0
- walla/search/__init__.py +15 -0
- walla/search/api.py +78 -0
- walla/search/parse.py +150 -0
- walla_cli-0.1.0.dist-info/METADATA +204 -0
- walla_cli-0.1.0.dist-info/RECORD +46 -0
- walla_cli-0.1.0.dist-info/WHEEL +4 -0
- walla_cli-0.1.0.dist-info/entry_points.txt +4 -0
- walla_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
walla/core/errors.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Shared error classification for CLI and MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from walla.core.exceptions import (
|
|
9
|
+
WallaAmbiguousError,
|
|
10
|
+
WallaAuthError,
|
|
11
|
+
WallaError,
|
|
12
|
+
WallaForbiddenError,
|
|
13
|
+
WallaHTTPError,
|
|
14
|
+
WallaNotFoundError,
|
|
15
|
+
WallaParseError,
|
|
16
|
+
WallaRateLimitError,
|
|
17
|
+
WallaUnsupportedError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = (
|
|
21
|
+
"ErrorClassification",
|
|
22
|
+
"classify_error",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ErrorClassification:
|
|
28
|
+
error_type: str
|
|
29
|
+
retryable: bool
|
|
30
|
+
http_status: int | None = None
|
|
31
|
+
retry_after_s: float | None = None
|
|
32
|
+
|
|
33
|
+
def as_fields(self) -> dict[str, Any]:
|
|
34
|
+
fields: dict[str, Any] = {
|
|
35
|
+
"error_type": self.error_type,
|
|
36
|
+
"retryable": self.retryable,
|
|
37
|
+
}
|
|
38
|
+
if self.http_status is not None:
|
|
39
|
+
fields["http_status"] = self.http_status
|
|
40
|
+
if self.retry_after_s is not None:
|
|
41
|
+
fields["retry_after_s"] = self.retry_after_s
|
|
42
|
+
return fields
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def classify_error(exc: BaseException) -> ErrorClassification:
|
|
46
|
+
"""Map an exception to a stable ``error_type`` + ``retryable`` pair."""
|
|
47
|
+
cause = getattr(exc, "last_attempt", None)
|
|
48
|
+
if cause is not None:
|
|
49
|
+
try:
|
|
50
|
+
inner = cause.exception()
|
|
51
|
+
if inner is not None:
|
|
52
|
+
return classify_error(inner)
|
|
53
|
+
except Exception: # noqa: BLE001
|
|
54
|
+
pass
|
|
55
|
+
if isinstance(exc, WallaAmbiguousError):
|
|
56
|
+
return ErrorClassification("ambiguous", retryable=False)
|
|
57
|
+
if isinstance(exc, WallaAuthError):
|
|
58
|
+
return ErrorClassification("auth", retryable=False)
|
|
59
|
+
if isinstance(exc, WallaUnsupportedError):
|
|
60
|
+
return ErrorClassification("unsupported", retryable=False)
|
|
61
|
+
if isinstance(exc, WallaNotFoundError):
|
|
62
|
+
return ErrorClassification("not_found", retryable=False)
|
|
63
|
+
if isinstance(exc, WallaParseError):
|
|
64
|
+
return ErrorClassification("parse_error", retryable=False)
|
|
65
|
+
if isinstance(exc, WallaRateLimitError):
|
|
66
|
+
return ErrorClassification(
|
|
67
|
+
"rate_limited",
|
|
68
|
+
retryable=True,
|
|
69
|
+
http_status=exc.status_code,
|
|
70
|
+
retry_after_s=exc.retry_after_s,
|
|
71
|
+
)
|
|
72
|
+
if isinstance(exc, WallaForbiddenError):
|
|
73
|
+
return ErrorClassification(
|
|
74
|
+
"forbidden", retryable=False, http_status=exc.status_code
|
|
75
|
+
)
|
|
76
|
+
if isinstance(exc, WallaHTTPError):
|
|
77
|
+
status = getattr(exc, "status_code", None)
|
|
78
|
+
retryable = status is not None and (status == 429 or status >= 500)
|
|
79
|
+
return ErrorClassification(
|
|
80
|
+
"http_error", retryable=retryable, http_status=status
|
|
81
|
+
)
|
|
82
|
+
if isinstance(exc, TimeoutError):
|
|
83
|
+
return ErrorClassification("timeout", retryable=True)
|
|
84
|
+
if isinstance(exc, ConnectionError | OSError):
|
|
85
|
+
return ErrorClassification("connection_error", retryable=True)
|
|
86
|
+
if isinstance(exc, ValueError):
|
|
87
|
+
return ErrorClassification("validation_error", retryable=False)
|
|
88
|
+
if isinstance(exc, WallaError):
|
|
89
|
+
return ErrorClassification("walla_error", retryable=False)
|
|
90
|
+
return ErrorClassification("unexpected_error", retryable=False)
|
walla/core/exceptions.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared walla error types (foundation -- importable by http + core)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"WallaAmbiguousError",
|
|
9
|
+
"WallaAuthError",
|
|
10
|
+
"WallaError",
|
|
11
|
+
"WallaForbiddenError",
|
|
12
|
+
"WallaHTTPError",
|
|
13
|
+
"WallaNotFoundError",
|
|
14
|
+
"WallaParseError",
|
|
15
|
+
"WallaRateLimitError",
|
|
16
|
+
"WallaUnsupportedError",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WallaError(Exception):
|
|
21
|
+
"""Base."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WallaHTTPError(WallaError):
|
|
25
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WallaRateLimitError(WallaHTTPError):
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
message: str,
|
|
34
|
+
*,
|
|
35
|
+
status_code: int = 429,
|
|
36
|
+
retry_after_s: float | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message, status_code=status_code)
|
|
39
|
+
self.retry_after_s = retry_after_s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WallaAuthError(WallaError):
|
|
43
|
+
"""Missing or rejected credentials / session."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WallaForbiddenError(WallaHTTPError):
|
|
47
|
+
"""Hard stop (403 circuit)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WallaParseError(WallaError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WallaNotFoundError(WallaError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class WallaUnsupportedError(WallaError):
|
|
59
|
+
"""Wire shape not captured yet."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WallaAmbiguousError(WallaError):
|
|
63
|
+
"""Multiple listings matched; caller must pick an id."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, *, candidates: list[Any]) -> None:
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.candidates = candidates
|
walla/core/human_fix.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""User-visible fix recipes (not system prompts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"cookie_export_fix",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cookie_export_fix() -> dict[str, Any]:
|
|
13
|
+
return {
|
|
14
|
+
"title": "Paste Wallapop session cookie",
|
|
15
|
+
"steps": [
|
|
16
|
+
"Log in at https://es.wallapop.com in your browser.",
|
|
17
|
+
"Copy cookie __Secure-next-auth.session-token (Cookie-Editor).",
|
|
18
|
+
"Run: walla login",
|
|
19
|
+
"Paste the cookie value when prompted.",
|
|
20
|
+
"Agents: walla login --cookie '<value>' --json",
|
|
21
|
+
],
|
|
22
|
+
"say_to_user": (
|
|
23
|
+
"Run `walla login` and paste your "
|
|
24
|
+
"__Secure-next-auth.session-token cookie from es.wallapop.com."
|
|
25
|
+
),
|
|
26
|
+
}
|
walla/core/instruct.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Agent recipe returned by ``walla instruct --json``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ("instruct_recipe",)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def instruct_recipe() -> dict[str, Any]:
|
|
11
|
+
return {
|
|
12
|
+
"name": "walla",
|
|
13
|
+
"tagline": "look · offer · talk",
|
|
14
|
+
"example": (
|
|
15
|
+
"Human: find a used kite near Barcelona under 400eur, message "
|
|
16
|
+
"seller, offer if GRAB. Agent: walla search -> verdict + item URL "
|
|
17
|
+
"-> ask confirm -> walla say --yes -> walla offer --eur N --yes "
|
|
18
|
+
"-> walla inbox."
|
|
19
|
+
),
|
|
20
|
+
"protocol": [
|
|
21
|
+
"Install: pipx install 'walla-cli[mcp]' (or pipx install -e '.[mcp]' in the clone).",
|
|
22
|
+
"walla doctor --json - never print tokens or cookies.",
|
|
23
|
+
"If no session: ask the human to run `walla login` and paste "
|
|
24
|
+
"__Secure-next-auth.session-token (or pass --cookie for non-interactive).",
|
|
25
|
+
"Do the ask with walla … --json and narrate in prose.",
|
|
26
|
+
],
|
|
27
|
+
"verbs": {
|
|
28
|
+
"look": ["walla search <q> --json", "walla item <id> --json"],
|
|
29
|
+
"talk": [
|
|
30
|
+
"walla inbox --json",
|
|
31
|
+
"walla thread <id> --json",
|
|
32
|
+
"walla say <id> <text> --yes",
|
|
33
|
+
],
|
|
34
|
+
"offer": ["walla offer <id> --eur <n> --yes"],
|
|
35
|
+
},
|
|
36
|
+
"rules": [
|
|
37
|
+
"search / item / inbox are safe reads.",
|
|
38
|
+
"say and offer require --yes / confirm=true; name listing + euros.",
|
|
39
|
+
"If only asked to look, do not send.",
|
|
40
|
+
"Ambiguous match: ask once with candidates.",
|
|
41
|
+
"Refuse in-person offers outside pickup radius.",
|
|
42
|
+
"No .env credentials. Login is cookie paste only (optional --password).",
|
|
43
|
+
],
|
|
44
|
+
"envelope": ["ok", "api_version", "data | error / error_type / retryable"],
|
|
45
|
+
"standards": [
|
|
46
|
+
"docs/code_quality.md",
|
|
47
|
+
"docs/data_engineering_standards.md",
|
|
48
|
+
"docs/WIRE.md",
|
|
49
|
+
],
|
|
50
|
+
}
|
walla/core/path.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Config paths under ``~/.config/walla`` (override ``WALLA_CONFIG_DIR``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
__all__ = (
|
|
9
|
+
"config_dir",
|
|
10
|
+
"profile_path",
|
|
11
|
+
"session_path",
|
|
12
|
+
"watches_path",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_dir() -> Path:
|
|
17
|
+
override = os.environ.get("WALLA_CONFIG_DIR")
|
|
18
|
+
if override:
|
|
19
|
+
return Path(override).expanduser()
|
|
20
|
+
return Path.home() / ".config" / "walla"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def profile_path() -> Path:
|
|
24
|
+
return config_dir() / "profile.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def session_path() -> Path:
|
|
28
|
+
return config_dir() / "session.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def watches_path() -> Path:
|
|
32
|
+
return config_dir() / "watches.json"
|
walla/http/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""HTTP package facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from walla.http.client import HttpClient
|
|
6
|
+
from walla.http.headers import API_BASE, WEB_BASE
|
|
7
|
+
from walla.http.polite import reset_polite
|
|
8
|
+
|
|
9
|
+
__all__ = (
|
|
10
|
+
"API_BASE",
|
|
11
|
+
"WEB_BASE",
|
|
12
|
+
"HttpClient",
|
|
13
|
+
"reset_polite",
|
|
14
|
+
)
|
walla/http/client.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""curl_cffi HTTP client with politeness and retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
from curl_cffi import requests
|
|
9
|
+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
|
10
|
+
|
|
11
|
+
from walla.core.exceptions import (
|
|
12
|
+
WallaForbiddenError,
|
|
13
|
+
WallaHTTPError,
|
|
14
|
+
WallaRateLimitError,
|
|
15
|
+
)
|
|
16
|
+
from walla.http.headers import API_BASE, default_headers
|
|
17
|
+
from walla.http.polite import (
|
|
18
|
+
cache_get,
|
|
19
|
+
cache_key,
|
|
20
|
+
cache_set,
|
|
21
|
+
check_circuit,
|
|
22
|
+
trip_circuit,
|
|
23
|
+
wait_turn,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = (
|
|
27
|
+
"HttpClient",
|
|
28
|
+
"parse_retry_after",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
Method = Literal["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "TRACE", "PATCH"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
35
|
+
if not value:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return float(value)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _retryable_http(exc: BaseException) -> bool:
|
|
44
|
+
if isinstance(exc, WallaForbiddenError):
|
|
45
|
+
return False
|
|
46
|
+
if isinstance(exc, WallaRateLimitError):
|
|
47
|
+
return True
|
|
48
|
+
if isinstance(exc, WallaHTTPError):
|
|
49
|
+
code = exc.status_code or 0
|
|
50
|
+
return code >= 500
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpClient:
|
|
55
|
+
"""Thin Wallapop HTTP shell. No import-time network."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
access_token: str | None = None,
|
|
61
|
+
device_id: str | None = None,
|
|
62
|
+
base: str = API_BASE,
|
|
63
|
+
cookies: dict[str, str] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.base = base.rstrip("/")
|
|
66
|
+
self.access_token = access_token
|
|
67
|
+
self.device_id = device_id
|
|
68
|
+
self.cookies = cookies or {}
|
|
69
|
+
self._session: Any = requests.Session()
|
|
70
|
+
|
|
71
|
+
def request(
|
|
72
|
+
self,
|
|
73
|
+
method: str,
|
|
74
|
+
path: str,
|
|
75
|
+
*,
|
|
76
|
+
params: dict[str, Any] | None = None,
|
|
77
|
+
json_body: dict[str, Any] | None = None,
|
|
78
|
+
use_cache: bool = False,
|
|
79
|
+
auth: bool = False,
|
|
80
|
+
) -> Any:
|
|
81
|
+
check_circuit()
|
|
82
|
+
params = params or {}
|
|
83
|
+
key = cache_key(f"{method}:{path}", params)
|
|
84
|
+
if use_cache and method.upper() == "GET":
|
|
85
|
+
hit = cache_get(key)
|
|
86
|
+
if hit is not None:
|
|
87
|
+
return hit
|
|
88
|
+
wait_turn()
|
|
89
|
+
headers = default_headers(device_id=self.device_id)
|
|
90
|
+
if auth and self.access_token:
|
|
91
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
92
|
+
url = path if path.startswith("http") else f"{self.base}{path}"
|
|
93
|
+
resp = self._send(method, url, headers=headers, params=params, json_body=json_body)
|
|
94
|
+
data = self._decode(resp)
|
|
95
|
+
if use_cache and method.upper() == "GET":
|
|
96
|
+
cache_set(key, data)
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
100
|
+
return self.request("GET", path, **kwargs)
|
|
101
|
+
|
|
102
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
103
|
+
return self.request("POST", path, **kwargs)
|
|
104
|
+
|
|
105
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
106
|
+
return self.request("DELETE", path, **kwargs)
|
|
107
|
+
|
|
108
|
+
@retry(
|
|
109
|
+
retry=retry_if_exception(_retryable_http),
|
|
110
|
+
stop=stop_after_attempt(3),
|
|
111
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=8),
|
|
112
|
+
reraise=True,
|
|
113
|
+
)
|
|
114
|
+
def _send(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
url: str,
|
|
118
|
+
*,
|
|
119
|
+
headers: dict[str, str],
|
|
120
|
+
params: dict[str, Any],
|
|
121
|
+
json_body: dict[str, Any] | None,
|
|
122
|
+
) -> Any:
|
|
123
|
+
resp = self._session.request(
|
|
124
|
+
cast(Method, method.upper()),
|
|
125
|
+
url,
|
|
126
|
+
headers=headers,
|
|
127
|
+
params=params or None,
|
|
128
|
+
json=json_body,
|
|
129
|
+
cookies=self.cookies or None,
|
|
130
|
+
impersonate="chrome",
|
|
131
|
+
timeout=30,
|
|
132
|
+
)
|
|
133
|
+
if resp.status_code == 429:
|
|
134
|
+
ra = parse_retry_after(resp.headers.get("Retry-After"))
|
|
135
|
+
if ra:
|
|
136
|
+
time.sleep(ra)
|
|
137
|
+
raise WallaRateLimitError(
|
|
138
|
+
"Wallapop rate limited (429)",
|
|
139
|
+
retry_after_s=ra,
|
|
140
|
+
)
|
|
141
|
+
if resp.status_code == 403:
|
|
142
|
+
trip_circuit()
|
|
143
|
+
raise WallaForbiddenError(
|
|
144
|
+
"Wallapop Forbidden (403) -- circuit open",
|
|
145
|
+
status_code=403,
|
|
146
|
+
)
|
|
147
|
+
if resp.status_code >= 500:
|
|
148
|
+
raise WallaHTTPError(
|
|
149
|
+
f"Wallapop server error {resp.status_code}",
|
|
150
|
+
status_code=resp.status_code,
|
|
151
|
+
)
|
|
152
|
+
return resp
|
|
153
|
+
|
|
154
|
+
def _decode(self, resp: Any) -> Any:
|
|
155
|
+
if resp.status_code in (200, 201, 204):
|
|
156
|
+
if not resp.content:
|
|
157
|
+
return {}
|
|
158
|
+
try:
|
|
159
|
+
return cast(Any, resp.json())
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
raise WallaHTTPError(
|
|
162
|
+
f"Invalid JSON ({resp.status_code})",
|
|
163
|
+
status_code=resp.status_code,
|
|
164
|
+
) from exc
|
|
165
|
+
body = (resp.text or "")[:300]
|
|
166
|
+
raise WallaHTTPError(
|
|
167
|
+
f"HTTP {resp.status_code}: {body}",
|
|
168
|
+
status_code=resp.status_code,
|
|
169
|
+
)
|
walla/http/headers.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Default browser-like headers for api.wallapop.com."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"API_BASE",
|
|
9
|
+
"WEB_BASE",
|
|
10
|
+
"default_headers",
|
|
11
|
+
"new_device_id",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
API_BASE = "https://api.wallapop.com"
|
|
15
|
+
WEB_BASE = "https://es.wallapop.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_device_id() -> str:
|
|
19
|
+
return str(uuid.uuid4())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_headers(*, device_id: str | None = None) -> dict[str, str]:
|
|
23
|
+
did = device_id or new_device_id()
|
|
24
|
+
return {
|
|
25
|
+
"Accept": "application/json",
|
|
26
|
+
"User-Agent": (
|
|
27
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
28
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
29
|
+
),
|
|
30
|
+
"Origin": WEB_BASE,
|
|
31
|
+
"Referer": f"{WEB_BASE}/",
|
|
32
|
+
"X-DeviceOS": "0",
|
|
33
|
+
"DeviceOS": "0",
|
|
34
|
+
"X-DeviceID": did,
|
|
35
|
+
"DeviceID": did,
|
|
36
|
+
}
|
walla/http/polite.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Process-wide Wallapop politeness: spacing, short TTL cache, ban circuit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from walla.core.exceptions import WallaForbiddenError
|
|
13
|
+
|
|
14
|
+
__all__ = (
|
|
15
|
+
"CACHE_MAX",
|
|
16
|
+
"CACHE_TTL",
|
|
17
|
+
"CIRCUIT_TTL",
|
|
18
|
+
"MIN_INTERVAL",
|
|
19
|
+
"cache_get",
|
|
20
|
+
"cache_key",
|
|
21
|
+
"cache_set",
|
|
22
|
+
"check_circuit",
|
|
23
|
+
"reset_polite",
|
|
24
|
+
"trip_circuit",
|
|
25
|
+
"wait_turn",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
MIN_INTERVAL = float(os.environ.get("WALLA_MIN_INTERVAL", "1.0"))
|
|
29
|
+
CACHE_TTL = float(os.environ.get("WALLA_CACHE_TTL", "60"))
|
|
30
|
+
CIRCUIT_TTL = float(os.environ.get("WALLA_CIRCUIT_TTL", "3600"))
|
|
31
|
+
CACHE_MAX = int(os.environ.get("WALLA_CACHE_MAX", "256"))
|
|
32
|
+
|
|
33
|
+
_lock = threading.Lock()
|
|
34
|
+
_last_request_at = 0.0
|
|
35
|
+
_circuit_open_until = 0.0
|
|
36
|
+
_cache: dict[str, tuple[float, Any]] = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def reset_polite() -> None:
|
|
40
|
+
"""Clear spacing / cache / circuit (tests)."""
|
|
41
|
+
global _last_request_at, _circuit_open_until, _cache
|
|
42
|
+
with _lock:
|
|
43
|
+
_last_request_at = 0.0
|
|
44
|
+
_circuit_open_until = 0.0
|
|
45
|
+
_cache = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cache_key(base: str, params: dict[str, Any]) -> str:
|
|
49
|
+
payload = json.dumps({"base": base, "params": params}, sort_keys=True, default=str)
|
|
50
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cache_get(key: str) -> Any | None:
|
|
54
|
+
now = time.monotonic()
|
|
55
|
+
with _lock:
|
|
56
|
+
hit = _cache.get(key)
|
|
57
|
+
if hit is None:
|
|
58
|
+
return None
|
|
59
|
+
expires, value = hit
|
|
60
|
+
if expires <= now:
|
|
61
|
+
del _cache[key]
|
|
62
|
+
return None
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cache_set(key: str, value: Any, *, ttl: float | None = None) -> None:
|
|
67
|
+
life = CACHE_TTL if ttl is None else ttl
|
|
68
|
+
with _lock:
|
|
69
|
+
if len(_cache) >= CACHE_MAX:
|
|
70
|
+
now = time.monotonic()
|
|
71
|
+
expired = [k for k, (exp, _) in _cache.items() if exp <= now]
|
|
72
|
+
for k in expired:
|
|
73
|
+
del _cache[k]
|
|
74
|
+
while len(_cache) >= CACHE_MAX:
|
|
75
|
+
_cache.pop(next(iter(_cache)))
|
|
76
|
+
_cache[key] = (time.monotonic() + life, value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_circuit() -> None:
|
|
80
|
+
with _lock:
|
|
81
|
+
open_until = _circuit_open_until
|
|
82
|
+
if open_until and time.monotonic() < open_until:
|
|
83
|
+
raise WallaForbiddenError(
|
|
84
|
+
"Wallapop Forbidden circuit open -- stop requests. "
|
|
85
|
+
"Wait for WALLA_CIRCUIT_TTL or reset_polite().",
|
|
86
|
+
status_code=403,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def trip_circuit(*, ttl: float | None = None) -> None:
|
|
91
|
+
life = CIRCUIT_TTL if ttl is None else ttl
|
|
92
|
+
global _circuit_open_until
|
|
93
|
+
with _lock:
|
|
94
|
+
_circuit_open_until = time.monotonic() + max(life, 0.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def wait_turn(*, min_interval: float | None = None) -> None:
|
|
98
|
+
"""Serialize dials with a minimum gap (sleep outside the lock)."""
|
|
99
|
+
gap = MIN_INTERVAL if min_interval is None else min_interval
|
|
100
|
+
global _last_request_at
|
|
101
|
+
while True:
|
|
102
|
+
with _lock:
|
|
103
|
+
now = time.monotonic()
|
|
104
|
+
delay = (_last_request_at + gap) - now
|
|
105
|
+
if delay <= 0:
|
|
106
|
+
_last_request_at = now
|
|
107
|
+
return
|
|
108
|
+
time.sleep(delay)
|
walla/hunter/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Hunter package facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from walla.hunter.profile_store import load_profile, parse_intake, save_profile
|
|
6
|
+
from walla.hunter.verdict import score_listing
|
|
7
|
+
from walla.hunter.watches import Watch, diff_new_ids, load_watches, save_watches
|
|
8
|
+
|
|
9
|
+
__all__ = (
|
|
10
|
+
"Watch",
|
|
11
|
+
"diff_new_ids",
|
|
12
|
+
"load_profile",
|
|
13
|
+
"load_watches",
|
|
14
|
+
"parse_intake",
|
|
15
|
+
"save_profile",
|
|
16
|
+
"save_watches",
|
|
17
|
+
"score_listing",
|
|
18
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Profile store on disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from walla.core.path import profile_path
|
|
9
|
+
from walla.models.profile import Profile
|
|
10
|
+
|
|
11
|
+
__all__ = (
|
|
12
|
+
"load_profile",
|
|
13
|
+
"parse_intake",
|
|
14
|
+
"save_profile",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_profile(path: Path | None = None) -> Profile:
|
|
19
|
+
p = path or profile_path()
|
|
20
|
+
if not p.is_file():
|
|
21
|
+
return Profile()
|
|
22
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
23
|
+
return Profile.model_validate(data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_profile(profile: Profile, path: Path | None = None) -> Path:
|
|
27
|
+
p = path or profile_path()
|
|
28
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
p.write_text(profile.model_dump_json(indent=2), encoding="utf-8")
|
|
30
|
+
return p
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_intake(text: str) -> Profile:
|
|
34
|
+
"""Parse ``key=value`` intake line into a Profile."""
|
|
35
|
+
fields: dict[str, str] = {}
|
|
36
|
+
for part in text.replace(",", " ").split():
|
|
37
|
+
if "=" not in part:
|
|
38
|
+
continue
|
|
39
|
+
k, _, v = part.partition("=")
|
|
40
|
+
fields[k.strip().lower()] = v.strip()
|
|
41
|
+
profile = load_profile()
|
|
42
|
+
if "lat" in fields:
|
|
43
|
+
profile.lat = float(fields["lat"])
|
|
44
|
+
if "lon" in fields:
|
|
45
|
+
profile.lon = float(fields["lon"])
|
|
46
|
+
if "km" in fields:
|
|
47
|
+
profile.km = float(fields["km"])
|
|
48
|
+
if "pickup_km" in fields:
|
|
49
|
+
profile.pickup_km = float(fields["pickup_km"])
|
|
50
|
+
if "label" in fields:
|
|
51
|
+
profile.label = fields["label"]
|
|
52
|
+
if "budget" in fields:
|
|
53
|
+
profile.budget = float(fields["budget"])
|
|
54
|
+
if "max_price" in fields:
|
|
55
|
+
profile.max_price = float(fields["max_price"])
|
|
56
|
+
return profile
|