cli-web-amazon 0.1.1__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.
@@ -0,0 +1,355 @@
1
+ """E2E and subprocess tests for cli-web-amazon."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+ import pytest
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # _resolve_cli helper
13
+ # ---------------------------------------------------------------------------
14
+
15
+
16
+ def _resolve_cli(name: str):
17
+ """Resolve installed CLI command; falls back to python -m for dev."""
18
+ force = os.environ.get("CLI_WEB_FORCE_INSTALLED", "").strip() == "1"
19
+ path = shutil.which(name)
20
+ if path:
21
+ return [path]
22
+ if force:
23
+ raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
24
+ module = name.replace("cli-web-", "cli_web.") + "." + name.split("-")[-1] + "_cli"
25
+ return [sys.executable, "-m", module]
26
+
27
+
28
+ CLI = _resolve_cli("cli-web-amazon")
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Live E2E — public commands (no auth required)
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ @pytest.mark.e2e
37
+ class TestE2ESuggest:
38
+ """cli-web-amazon suggest — live autocomplete API."""
39
+
40
+ def test_suggest_returns_results(self):
41
+ from cli_web.amazon.core.client import AmazonClient
42
+
43
+ with AmazonClient() as client:
44
+ suggestions = client.get_suggestions("laptop")
45
+ assert len(suggestions) > 0, "Suggest returned empty list for 'laptop'"
46
+ assert suggestions[0].value, "First suggestion has no value"
47
+ assert suggestions[0].type, "First suggestion has no type"
48
+
49
+ def test_suggest_keyword_type(self):
50
+ from cli_web.amazon.core.client import AmazonClient
51
+
52
+ with AmazonClient() as client:
53
+ suggestions = client.get_suggestions("headphones")
54
+ types = {s.type for s in suggestions}
55
+ assert "KEYWORD" in types, f"Expected KEYWORD type in suggestions; got: {types}"
56
+
57
+ def test_suggest_no_rpc_leakage(self):
58
+ from cli_web.amazon.core.client import AmazonClient
59
+
60
+ with AmazonClient() as client:
61
+ suggestions = client.get_suggestions("phone")
62
+ for s in suggestions:
63
+ assert "wrb.fr" not in s.value, "Raw RPC data leaked into suggestion"
64
+ assert "af.httprm" not in s.value, "Raw RPC data leaked into suggestion"
65
+
66
+ def test_suggest_unusual_query_returns_list(self):
67
+ from cli_web.amazon.core.client import AmazonClient
68
+
69
+ with AmazonClient() as client:
70
+ suggestions = client.get_suggestions("xyzzy12345noresults")
71
+ # May return empty list — just must not raise
72
+ assert isinstance(suggestions, list)
73
+
74
+
75
+ @pytest.mark.e2e
76
+ class TestE2ESearch:
77
+ """cli-web-amazon search — live HTML search."""
78
+
79
+ def test_search_returns_asins(self):
80
+ from cli_web.amazon.core.client import AmazonClient
81
+
82
+ with AmazonClient() as client:
83
+ results = client.search("laptop")
84
+ assert len(results) > 0, "Search returned no results for 'laptop'"
85
+ for r in results:
86
+ assert len(r.asin) == 10, f"ASIN {r.asin!r} does not look like a valid ASIN"
87
+
88
+ def test_search_result_fields(self):
89
+ from cli_web.amazon.core.client import AmazonClient
90
+
91
+ with AmazonClient() as client:
92
+ results = client.search("laptop")
93
+ first = results[0]
94
+ assert first.title, "First search result has no title"
95
+ assert first.url.startswith("https://www.amazon.com"), f"URL looks wrong: {first.url}"
96
+
97
+ def test_search_url_uses_asin(self):
98
+ from cli_web.amazon.core.client import AmazonClient
99
+
100
+ with AmazonClient() as client:
101
+ results = client.search("laptop")
102
+ first = results[0]
103
+ assert first.asin in first.url, "ASIN not present in product URL"
104
+
105
+ def test_search_pagination(self):
106
+ from cli_web.amazon.core.client import AmazonClient
107
+
108
+ with AmazonClient() as client:
109
+ page1 = client.search("laptop", page=1)
110
+ page2 = client.search("laptop", page=2)
111
+ # Both pages should have results; they should not be identical
112
+ assert len(page1) > 0
113
+ assert len(page2) > 0
114
+ asin1 = {r.asin for r in page1}
115
+ asin2 = {r.asin for r in page2}
116
+ assert asin1 != asin2, "Page 1 and page 2 returned identical ASINs"
117
+
118
+ def test_search_no_rpc_leakage(self):
119
+ from cli_web.amazon.core.client import AmazonClient
120
+
121
+ with AmazonClient() as client:
122
+ results = client.search("laptop")
123
+ for r in results:
124
+ assert "wrb.fr" not in r.title, "Raw RPC data in search title"
125
+
126
+
127
+ @pytest.mark.e2e
128
+ class TestE2EProduct:
129
+ """cli-web-amazon product get — live product detail page."""
130
+
131
+ KNOWN_ASIN = "B0GRZ78683" # Dell Inspiron 15 (stable listing)
132
+
133
+ def test_get_product_returns_data(self):
134
+ from cli_web.amazon.core.client import AmazonClient
135
+
136
+ with AmazonClient() as client:
137
+ product = client.get_product(self.KNOWN_ASIN)
138
+ assert product.asin == self.KNOWN_ASIN
139
+ assert product.title, "Product title is empty"
140
+ assert len(product.title) > 10, "Product title suspiciously short"
141
+
142
+ def test_get_product_url_is_dp_url(self):
143
+ from cli_web.amazon.core.client import AmazonClient
144
+
145
+ with AmazonClient() as client:
146
+ product = client.get_product(self.KNOWN_ASIN)
147
+ assert self.KNOWN_ASIN in product.url, "ASIN not in product URL"
148
+ assert product.url.startswith("https://www.amazon.com"), "URL doesn't start with amazon.com"
149
+
150
+ def test_get_product_rating_format(self):
151
+ from cli_web.amazon.core.client import AmazonClient
152
+
153
+ with AmazonClient() as client:
154
+ product = client.get_product(self.KNOWN_ASIN)
155
+ if product.rating:
156
+ assert "out of 5" in product.rating, f"Unexpected rating format: {product.rating}"
157
+
158
+ def test_search_then_get_round_trip(self):
159
+ """Search → pick first ASIN → get product → verify title matches."""
160
+ from cli_web.amazon.core.client import AmazonClient
161
+
162
+ with AmazonClient() as client:
163
+ results = client.search("laptop")
164
+ assert len(results) > 0
165
+ asin = results[0].asin
166
+ product = client.get_product(asin)
167
+ assert product.asin == asin, "Product ASIN mismatch"
168
+ assert product.title, "Product returned by get has no title"
169
+
170
+ def test_get_product_no_rpc_leakage(self):
171
+ from cli_web.amazon.core.client import AmazonClient
172
+
173
+ with AmazonClient() as client:
174
+ product = client.get_product(self.KNOWN_ASIN)
175
+ assert "wrb.fr" not in product.title, "Raw RPC data in product title"
176
+ assert "af.httprm" not in (product.title + str(product.price)), "Raw RPC data in product"
177
+
178
+
179
+ @pytest.mark.e2e
180
+ class TestE2EBestSellers:
181
+ """cli-web-amazon bestsellers — live bestseller page."""
182
+
183
+ def test_bestsellers_electronics(self):
184
+ from cli_web.amazon.core.client import AmazonClient
185
+
186
+ with AmazonClient() as client:
187
+ items = client.get_bestsellers("electronics")
188
+ assert len(items) > 0, "Bestsellers returned no items for electronics"
189
+ assert items[0].rank == 1, f"First item rank should be 1, got {items[0].rank}"
190
+
191
+ def test_bestsellers_asin_length(self):
192
+ from cli_web.amazon.core.client import AmazonClient
193
+
194
+ with AmazonClient() as client:
195
+ items = client.get_bestsellers("electronics")
196
+ for item in items:
197
+ assert len(item.asin) == 10, f"ASIN {item.asin!r} not 10 chars"
198
+
199
+ def test_bestsellers_rank_sequential(self):
200
+ from cli_web.amazon.core.client import AmazonClient
201
+
202
+ with AmazonClient() as client:
203
+ items = client.get_bestsellers("electronics")
204
+ ranks = [i.rank for i in items]
205
+ assert ranks == sorted(ranks), "Ranks are not in ascending order"
206
+ assert ranks[0] == 1, "First rank is not 1"
207
+
208
+ def test_bestsellers_title_not_empty(self):
209
+ from cli_web.amazon.core.client import AmazonClient
210
+
211
+ with AmazonClient() as client:
212
+ items = client.get_bestsellers("books")
213
+ for item in items:
214
+ assert item.title, f"Item with ASIN {item.asin} has empty title"
215
+
216
+ def test_bestsellers_url_contains_asin(self):
217
+ from cli_web.amazon.core.client import AmazonClient
218
+
219
+ with AmazonClient() as client:
220
+ items = client.get_bestsellers("electronics")
221
+ for item in items:
222
+ assert item.asin in item.url, f"ASIN {item.asin} not in URL {item.url}"
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Subprocess tests — full CLI invocations
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ @pytest.mark.e2e
231
+ class TestCLISubprocess:
232
+ """End-to-end subprocess tests using the installed cli-web-amazon binary."""
233
+
234
+ def _run(self, args, check=False):
235
+ return subprocess.run(
236
+ CLI + args,
237
+ capture_output=True,
238
+ text=True,
239
+ encoding="utf-8",
240
+ errors="replace",
241
+ check=check,
242
+ )
243
+
244
+ def test_help_loads(self):
245
+ result = self._run(["--help"])
246
+ assert result.returncode == 0
247
+ assert "cli-web-amazon" in result.stdout.lower() or "amazon" in result.stdout.lower()
248
+
249
+ def test_version(self):
250
+ result = self._run(["--version"])
251
+ assert result.returncode == 0
252
+
253
+ def test_search_json_output(self):
254
+ result = self._run(["search", "laptop", "--json"])
255
+ assert result.returncode == 0, f"stderr: {result.stderr}"
256
+ data = json.loads(result.stdout)
257
+ assert isinstance(data, list), "Expected JSON array"
258
+ assert len(data) > 0, "Search returned empty list"
259
+ first = data[0]
260
+ assert "asin" in first
261
+ assert "title" in first
262
+ assert len(first["asin"]) == 10, "ASIN not 10 chars"
263
+
264
+ def test_search_no_rpc_leak(self):
265
+ result = self._run(["search", "laptop", "--json"])
266
+ assert result.returncode == 0
267
+ data = json.loads(result.stdout)
268
+ for item in data:
269
+ assert "wrb.fr" not in item.get("title", ""), "Raw RPC data in title"
270
+ assert "af.httprm" not in item.get("title", ""), "Raw RPC data in title"
271
+
272
+ def test_suggest_json_output(self):
273
+ result = self._run(["suggest", "laptop", "--json"])
274
+ assert result.returncode == 0, f"stderr: {result.stderr}"
275
+ data = json.loads(result.stdout)
276
+ assert isinstance(data, list)
277
+ assert len(data) > 0
278
+ assert "value" in data[0]
279
+ assert "type" in data[0]
280
+
281
+ def test_product_get_json_output(self):
282
+ result = self._run(["product", "get", "B0GRZ78683", "--json"])
283
+ assert result.returncode == 0, f"stderr: {result.stderr}"
284
+ data = json.loads(result.stdout)
285
+ assert data["asin"] == "B0GRZ78683"
286
+ assert data["title"], "Product title is empty"
287
+ assert "url" in data
288
+ assert "B0GRZ78683" in data["url"]
289
+
290
+ def test_product_get_no_rpc_leak(self):
291
+ result = self._run(["product", "get", "B0GRZ78683", "--json"])
292
+ assert result.returncode == 0
293
+ data = json.loads(result.stdout)
294
+ assert "wrb.fr" not in data.get("title", ""), "Raw RPC data in product title"
295
+
296
+ def test_bestsellers_json_output(self):
297
+ result = self._run(["bestsellers", "electronics", "--json"])
298
+ assert result.returncode == 0, f"stderr: {result.stderr}"
299
+ data = json.loads(result.stdout)
300
+ assert isinstance(data, list)
301
+ assert len(data) > 0
302
+ assert data[0]["rank"] == 1
303
+
304
+ def test_bestsellers_required_fields(self):
305
+ result = self._run(["bestsellers", "electronics", "--json"])
306
+ assert result.returncode == 0
307
+ data = json.loads(result.stdout)
308
+ for item in data:
309
+ assert "asin" in item
310
+ assert "title" in item
311
+ assert "rank" in item
312
+
313
+ def test_search_with_page(self):
314
+ result = self._run(["search", "laptop", "--page", "2", "--json"])
315
+ assert result.returncode == 0, f"stderr: {result.stderr}"
316
+ data = json.loads(result.stdout)
317
+ assert isinstance(data, list)
318
+
319
+ def test_search_with_dept(self):
320
+ result = self._run(["search", "laptop", "--dept", "electronics", "--json"])
321
+ assert result.returncode == 0, f"stderr: {result.stderr}"
322
+ data = json.loads(result.stdout)
323
+ assert isinstance(data, list)
324
+
325
+ def test_product_get_unknown_asin_error(self):
326
+ """Unknown ASIN should return a structured error, not a crash."""
327
+ result = self._run(["product", "get", "BADASIN000", "--json"])
328
+ # May return error or empty product — should not crash
329
+ if result.returncode != 0:
330
+ try:
331
+ data = json.loads(result.stdout)
332
+ assert "error" in data
333
+ except json.JSONDecodeError:
334
+ pytest.fail("CLI crashed with non-JSON output on unknown ASIN")
335
+
336
+ def test_search_help_subcommand(self):
337
+ result = self._run(["search", "--help"])
338
+ assert result.returncode == 0
339
+ assert "search" in result.stdout.lower()
340
+
341
+ def test_product_help_subcommand(self):
342
+ result = self._run(["product", "--help"])
343
+ assert result.returncode == 0
344
+
345
+ def test_bestsellers_help_subcommand(self):
346
+ result = self._run(["bestsellers", "--help"])
347
+ assert result.returncode == 0
348
+
349
+ def test_suggest_help_subcommand(self):
350
+ result = self._run(["suggest", "--help"])
351
+ assert result.returncode == 0
352
+
353
+
354
+ if __name__ == "__main__":
355
+ pytest.main([__file__, "-v"])
@@ -0,0 +1 @@
1
+ """Utilities for cli-web-amazon."""
@@ -0,0 +1,5 @@
1
+ """Configuration helpers for cli-web-amazon."""
2
+
3
+ from pathlib import Path
4
+
5
+ CONFIG_DIR = Path.home() / ".config" / "cli-web-amazon"
@@ -0,0 +1,188 @@
1
+ """``doctor`` — self-diagnosis for cli-web-* CLIs.
2
+
3
+ CANONICAL SOURCE: cli-web-core/cli_web_core/doctor.py
4
+ Vendored into every generated CLI at cli_web/<app>/utils/doctor.py by
5
+ `cli-web-devkit resync`. Do not edit vendored copies by hand.
6
+
7
+ Checks the local environment a support thread would ask about first:
8
+ installation, Python version, config directory, auth material (when the
9
+ CLI has an auth module), and optional dependencies. Read-only — never
10
+ mutates state, never touches the network.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import json
17
+ import os
18
+ import shutil
19
+ import stat
20
+ import sys
21
+ from dataclasses import asdict, dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ @dataclass
27
+ class DoctorCheck:
28
+ name: str
29
+ status: str # "ok" | "warn" | "fail"
30
+ detail: str = ""
31
+
32
+
33
+ def _check_entry_point(app_name: str) -> DoctorCheck:
34
+ binary = f"cli-web-{app_name}"
35
+ path = shutil.which(binary)
36
+ if path:
37
+ return DoctorCheck("entry point", "ok", path)
38
+ return DoctorCheck(
39
+ "entry point",
40
+ "warn",
41
+ f"{binary} not on PATH — run `pip install -e .` in agent-harness/ "
42
+ f"(python -m fallback still works)",
43
+ )
44
+
45
+
46
+ def _check_python() -> DoctorCheck:
47
+ # Intentional runtime guard: direct-source runs bypass pip's
48
+ # python_requires, so the interpreter check must live here.
49
+ if sys.version_info >= (3, 10): # noqa: UP036
50
+ return DoctorCheck("python", "ok", sys.version.split()[0])
51
+ return DoctorCheck("python", "fail", f"{sys.version.split()[0]} < 3.10 (unsupported)")
52
+
53
+
54
+ def _config_dir(app_name: str) -> Path:
55
+ return Path.home() / ".config" / f"cli-web-{app_name}"
56
+
57
+
58
+ def _check_config_dir(app_name: str) -> DoctorCheck:
59
+ cfg = _config_dir(app_name)
60
+ if not cfg.exists():
61
+ return DoctorCheck("config dir", "ok", f"{cfg} (not created yet — created on first use)")
62
+ if os.access(cfg, os.W_OK):
63
+ return DoctorCheck("config dir", "ok", str(cfg))
64
+ return DoctorCheck("config dir", "fail", f"{cfg} is not writable")
65
+
66
+
67
+ def _has_auth_module(pkg: str) -> bool:
68
+ try:
69
+ return importlib.util.find_spec(f"cli_web.{pkg}.core.auth") is not None
70
+ except (ImportError, ModuleNotFoundError, ValueError):
71
+ return False
72
+
73
+
74
+ def _check_auth(app_name: str, pkg: str) -> list[DoctorCheck]:
75
+ if not _has_auth_module(pkg):
76
+ return [DoctorCheck("auth", "ok", "no auth module — public site, nothing to configure")]
77
+
78
+ checks: list[DoctorCheck] = []
79
+ if importlib.util.find_spec("playwright") is None:
80
+ checks.append(
81
+ DoctorCheck(
82
+ "playwright",
83
+ "warn",
84
+ "not installed — `auth login` (browser flow) unavailable; "
85
+ "pip install playwright && playwright install chromium",
86
+ )
87
+ )
88
+ else:
89
+ checks.append(DoctorCheck("playwright", "ok", "installed"))
90
+
91
+ env_var = f"CLI_WEB_{app_name.upper().replace('-', '_')}_AUTH_JSON"
92
+ if os.environ.get(env_var):
93
+ checks.append(DoctorCheck("auth source", "ok", f"using env var {env_var}"))
94
+ return checks
95
+
96
+ auth_file = _config_dir(app_name) / "auth.json"
97
+ if not auth_file.is_file():
98
+ checks.append(
99
+ DoctorCheck(
100
+ "auth file",
101
+ "warn",
102
+ f"{auth_file} missing — run: cli-web-{app_name} auth login (or set {env_var})",
103
+ )
104
+ )
105
+ return checks
106
+
107
+ checks.append(DoctorCheck("auth file", "ok", str(auth_file)))
108
+ if os.name == "posix": # st_mode permission bits are meaningless on Windows
109
+ mode = stat.S_IMODE(auth_file.stat().st_mode)
110
+ if mode & 0o077:
111
+ checks.append(
112
+ DoctorCheck(
113
+ "auth file permissions",
114
+ "warn",
115
+ f"{oct(mode)} — should be 600; run: chmod 600 {auth_file}",
116
+ )
117
+ )
118
+ else:
119
+ checks.append(DoctorCheck("auth file permissions", "ok", oct(mode)))
120
+ try:
121
+ json.loads(auth_file.read_text(encoding="utf-8"))
122
+ checks.append(DoctorCheck("auth file format", "ok", "valid JSON"))
123
+ except (OSError, json.JSONDecodeError) as exc:
124
+ checks.append(DoctorCheck("auth file format", "fail", f"unreadable: {exc}"))
125
+
126
+ return checks
127
+
128
+
129
+ def _check_optional_deps() -> list[DoctorCheck]:
130
+ checks = []
131
+ if importlib.util.find_spec("prompt_toolkit") is None:
132
+ checks.append(
133
+ DoctorCheck("prompt_toolkit", "ok", "not installed — REPL uses plain input()")
134
+ )
135
+ else:
136
+ checks.append(DoctorCheck("prompt_toolkit", "ok", "installed (REPL autocomplete on)"))
137
+ return checks
138
+
139
+
140
+ def run_doctor(app_name: str, pkg: str) -> list[DoctorCheck]:
141
+ checks = [
142
+ _check_python(),
143
+ _check_entry_point(app_name),
144
+ _check_config_dir(app_name),
145
+ *_check_auth(app_name, pkg),
146
+ *_check_optional_deps(),
147
+ ]
148
+ return checks
149
+
150
+
151
+ def register_doctor_command(cli: Any, app_name: str, pkg: str | None = None) -> None:
152
+ """Attach a ``doctor`` command to a cli-web-* Click group."""
153
+ import click
154
+
155
+ resolved_pkg = pkg or app_name.replace("-", "_")
156
+
157
+ @cli.command("doctor")
158
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
159
+ @click.pass_context
160
+ def doctor(ctx: Any, json_mode: bool) -> None:
161
+ """Diagnose this CLI's local setup (install, auth, dependencies)."""
162
+ if not json_mode: # honor the group-level --json flag (ctx.obj["json"])
163
+ obj = ctx.find_root().obj
164
+ json_mode = bool(obj.get("json")) if isinstance(obj, dict) else False
165
+ checks = run_doctor(app_name, resolved_pkg)
166
+ failed = [c for c in checks if c.status == "fail"]
167
+ if json_mode:
168
+ click.echo(
169
+ json.dumps(
170
+ {
171
+ "success": not failed,
172
+ "data": {
173
+ "checks": [asdict(c) for c in checks],
174
+ "ok": not failed,
175
+ },
176
+ },
177
+ indent=2,
178
+ )
179
+ )
180
+ else:
181
+ marks = {"ok": "✓", "warn": "⚠", "fail": "✗"}
182
+ for c in checks:
183
+ detail = f" {c.detail}" if c.detail else ""
184
+ click.echo(f" {marks[c.status]} {c.name}:{detail}")
185
+ click.echo()
186
+ click.echo("all good" if not failed else f"{len(failed)} problem(s) found")
187
+ if failed:
188
+ raise SystemExit(1)
@@ -0,0 +1,127 @@
1
+ """Shared CLI helpers for cli-web-amazon."""
2
+
3
+ import json
4
+ import sys
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from ..core.exceptions import (
12
+ AmazonError,
13
+ ParsingError,
14
+ RateLimitError,
15
+ error_code_for,
16
+ )
17
+
18
+ CONFIG_DIR = Path.home() / ".config" / "cli-web-amazon"
19
+ CONFIG_FILE = CONFIG_DIR / "config.json"
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # JSON output helper
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def print_json(data: Any) -> None:
28
+ """Print data as pretty JSON."""
29
+ click.echo(json.dumps(data, ensure_ascii=False, indent=2))
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Error handler context manager
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ @contextmanager
38
+ def handle_errors(json_mode: bool = False):
39
+ """Context manager that catches exceptions and outputs proper error messages.
40
+
41
+ Exit codes: 1=user/app error, 2=system error, 130=keyboard interrupt.
42
+ """
43
+ try:
44
+ yield
45
+ except KeyboardInterrupt:
46
+ if not json_mode:
47
+ click.echo("\nInterrupted.", err=True)
48
+ sys.exit(130)
49
+ except click.exceptions.Exit:
50
+ raise
51
+ except click.UsageError:
52
+ raise
53
+ except AmazonError as exc:
54
+ code = error_code_for(exc)
55
+ if json_mode:
56
+ err_dict: dict = {"error": True, "code": code, "message": str(exc)}
57
+ if isinstance(exc, RateLimitError) and exc.retry_after is not None:
58
+ err_dict["retry_after"] = exc.retry_after
59
+ click.echo(json.dumps(err_dict, ensure_ascii=False))
60
+ else:
61
+ hint = ""
62
+ if isinstance(exc, RateLimitError) and exc.retry_after:
63
+ hint = f"\n Hint: Retry after {exc.retry_after:.0f}s"
64
+ elif isinstance(exc, ParsingError):
65
+ hint = "\n Hint: Amazon page structure may have changed"
66
+ click.echo(f"Error: {exc}{hint}", err=True)
67
+ sys.exit(1)
68
+ except Exception as exc:
69
+ if json_mode:
70
+ click.echo(
71
+ json.dumps(
72
+ {"error": True, "code": "INTERNAL_ERROR", "message": str(exc)},
73
+ ensure_ascii=False,
74
+ )
75
+ )
76
+ else:
77
+ click.echo(f"Error: {exc}", err=True)
78
+ sys.exit(2)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Filename sanitization
83
+ # ---------------------------------------------------------------------------
84
+
85
+ _INVALID_CHARS = set('/\\:*?"<>|')
86
+
87
+
88
+ def sanitize_filename(name: str, max_length: int = 240) -> str:
89
+ """Convert a title to a safe filename."""
90
+ if not name or not name.strip():
91
+ return "untitled"
92
+ safe = "".join(c if c not in _INVALID_CHARS else "_" for c in name)
93
+ safe = safe.strip(". ")
94
+ return safe[:max_length] if safe else "untitled"
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Persistent config
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def _load_config() -> dict:
103
+ """Load config.json, returning empty dict on failure."""
104
+ try:
105
+ if CONFIG_FILE.exists():
106
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
107
+ except (json.JSONDecodeError, OSError):
108
+ pass
109
+ return {}
110
+
111
+
112
+ def _save_config(data: dict) -> None:
113
+ """Save config.json."""
114
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
115
+ CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
116
+
117
+
118
+ def get_config_value(key: str) -> Any:
119
+ """Get a value from persistent config."""
120
+ return _load_config().get(key)
121
+
122
+
123
+ def set_config_value(key: str, value: Any) -> None:
124
+ """Set a value in persistent config."""
125
+ cfg = _load_config()
126
+ cfg[key] = value
127
+ _save_config(cfg)