geonode-scraper-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.
@@ -0,0 +1,104 @@
1
+ """Configuration resolution: flag > env > TOML file > default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ try: # Python 3.11+
10
+ import tomllib
11
+ except ModuleNotFoundError: # Python 3.10
12
+ import tomli as tomllib # type: ignore[no-redef]
13
+
14
+ from geonode_scraper_tools_core import ScraperToolSettings
15
+
16
+ ENV_PREFIX = "GEONODE_SCRAPER_"
17
+ DEFAULT_PROFILE = "default"
18
+ DEFAULT_HOST = "http://localhost"
19
+
20
+
21
+ class ConfigError(Exception):
22
+ """Raised when required configuration is missing or a config file is invalid."""
23
+
24
+
25
+ def config_path() -> Path:
26
+ """Location of the TOML config file, honouring ``XDG_CONFIG_HOME``."""
27
+ override = os.environ.get(f"{ENV_PREFIX}CONFIG")
28
+ if override:
29
+ return Path(override).expanduser()
30
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
31
+ return Path(base) / "geonode-scraper" / "config.toml"
32
+
33
+
34
+ def load_profiles() -> dict[str, Any]:
35
+ path = config_path()
36
+ if not path.exists():
37
+ return {}
38
+ try:
39
+ return tomllib.loads(path.read_text(encoding="utf-8"))
40
+ except (tomllib.TOMLDecodeError, OSError) as exc:
41
+ raise ConfigError(f"Could not read config file {path}: {exc}") from exc
42
+
43
+
44
+ def _env(name: str) -> str | None:
45
+ return os.environ.get(f"{ENV_PREFIX}{name}")
46
+
47
+
48
+ def _as_bool(value: Any) -> bool:
49
+ if isinstance(value, bool):
50
+ return value
51
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
52
+
53
+
54
+ def resolve_profile_name(profile: str | None) -> str:
55
+ return profile or _env("PROFILE") or DEFAULT_PROFILE
56
+
57
+
58
+ def resolve_settings(
59
+ *,
60
+ api_key: str | None = None,
61
+ host: str | None = None,
62
+ profile: str | None = None,
63
+ verify_ssl: bool | None = None,
64
+ timeout: float | None = None,
65
+ ) -> ScraperToolSettings:
66
+ """Merge CLI flags, environment, and the config-file profile into settings.
67
+
68
+ Precedence (highest first): explicit flag, environment variable, config file,
69
+ built-in default.
70
+ """
71
+ profile_name = resolve_profile_name(profile)
72
+ profiles = load_profiles()
73
+ file_cfg = profiles.get(profile_name) or {}
74
+ if profile is not None and profile_name not in profiles:
75
+ raise ConfigError(f"Profile '{profile_name}' not found in {config_path()}.")
76
+
77
+ def pick(flag: Any, env_name: str, file_key: str, default: Any = None) -> Any:
78
+ if flag is not None:
79
+ return flag
80
+ env_value = _env(env_name)
81
+ if env_value is not None:
82
+ return env_value
83
+ if file_key in file_cfg:
84
+ return file_cfg[file_key]
85
+ return default
86
+
87
+ resolved_host = pick(host, "HOST", "host", DEFAULT_HOST)
88
+ resolved_key = pick(api_key, "API_KEY", "api_key")
89
+ resolved_verify = pick(verify_ssl, "VERIFY_SSL", "verify_ssl", True)
90
+ resolved_timeout = pick(timeout, "TIMEOUT", "timeout")
91
+
92
+ if not resolved_key:
93
+ raise ConfigError(
94
+ "No API key configured. Pass --api-key, set "
95
+ f"{ENV_PREFIX}API_KEY, or add 'api_key' to your config profile "
96
+ f"({config_path()})."
97
+ )
98
+
99
+ return ScraperToolSettings(
100
+ host=str(resolved_host),
101
+ api_key=str(resolved_key),
102
+ verify_ssl=_as_bool(resolved_verify),
103
+ request_timeout=float(resolved_timeout) if resolved_timeout is not None else None,
104
+ )
@@ -0,0 +1,59 @@
1
+ """Per-invocation CLI state and lazy service construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import typer
8
+ from geonode_scraper_tools_core import ScraperToolService, ScraperToolSettings
9
+
10
+ from .config import ConfigError, resolve_settings
11
+ from .errors import ExitCode
12
+ from .output import OutputFormat
13
+
14
+
15
+ @dataclass
16
+ class CLIContext:
17
+ """Holds resolved global options; builds the service on demand.
18
+
19
+ Credentials are only required when a command actually calls the API, so
20
+ ``--help``/``--version`` and the ``config`` commands never fail on a missing
21
+ API key.
22
+ """
23
+
24
+ api_key: str | None
25
+ host: str | None
26
+ profile: str | None
27
+ verify_ssl: bool | None
28
+ timeout: float | None
29
+ output: OutputFormat
30
+ verbose: bool
31
+
32
+ _settings: ScraperToolSettings | None = None
33
+ _service: ScraperToolService | None = None
34
+
35
+ def settings(self) -> ScraperToolSettings:
36
+ if self._settings is None:
37
+ try:
38
+ self._settings = resolve_settings(
39
+ api_key=self.api_key,
40
+ host=self.host,
41
+ profile=self.profile,
42
+ verify_ssl=self.verify_ssl,
43
+ timeout=self.timeout,
44
+ )
45
+ except ConfigError as exc:
46
+ typer.echo(str(exc), err=True)
47
+ raise typer.Exit(int(ExitCode.USAGE)) from exc
48
+ return self._settings
49
+
50
+ def service(self) -> ScraperToolService:
51
+ if self._service is None:
52
+ self._service = ScraperToolService(self.settings())
53
+ return self._service
54
+
55
+
56
+ def get_cli_context(ctx: typer.Context) -> CLIContext:
57
+ obj = ctx.obj
58
+ assert isinstance(obj, CLIContext) # set by the root callback
59
+ return obj
@@ -0,0 +1,50 @@
1
+ """Map service error envelopes and exceptions to user-facing messages + exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+ from typing import Any, Mapping
7
+
8
+
9
+ class ExitCode(IntEnum):
10
+ OK = 0
11
+ GENERIC = 1
12
+ USAGE = 2
13
+ AUTH = 4
14
+ NOT_FOUND = 5
15
+ VALIDATION = 6
16
+ NETWORK = 7
17
+ TIMEOUT = 8
18
+
19
+
20
+ def exit_code_for_error(error: Mapping[str, Any]) -> ExitCode:
21
+ """Choose a POSIX exit code from a service error dict."""
22
+ code = error.get("code")
23
+ if code == "POLL_TIMEOUT":
24
+ return ExitCode.TIMEOUT
25
+
26
+ status = error.get("status")
27
+ if status in (401, 403):
28
+ return ExitCode.AUTH
29
+ if status == 404:
30
+ return ExitCode.NOT_FOUND
31
+ if status == 422:
32
+ return ExitCode.VALIDATION
33
+ if status is None:
34
+ # No HTTP status reached the server: local/connection problem.
35
+ return ExitCode.NETWORK
36
+ return ExitCode.GENERIC
37
+
38
+
39
+ def format_error(error: Mapping[str, Any]) -> str:
40
+ """Build a single-line human message from a service error dict."""
41
+ message = error.get("message") or error.get("reason") or "Request failed."
42
+ status = error.get("status")
43
+ code = error.get("code")
44
+ prefix_parts = []
45
+ if status is not None:
46
+ prefix_parts.append(f"HTTP {status}")
47
+ if code:
48
+ prefix_parts.append(str(code))
49
+ prefix = f"[{' '.join(prefix_parts)}] " if prefix_parts else ""
50
+ return f"{prefix}{message}"
@@ -0,0 +1,56 @@
1
+ """Shared command plumbing: validate flags via tools-core schemas, call, emit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, NoReturn
6
+
7
+ import typer
8
+ from pydantic import BaseModel, ValidationError
9
+ from rich.console import Console
10
+
11
+ from .context import get_cli_context
12
+ from .errors import ExitCode
13
+ from .renderers import emit
14
+
15
+ _stderr = Console(stderr=True)
16
+
17
+
18
+ def validated(schema_cls: type[BaseModel], /, **kwargs: Any) -> dict[str, Any]:
19
+ """Validate user-supplied flags through a tools-core input schema.
20
+
21
+ Only pass keys the user actually provided; the schema fills defaults. Keeps
22
+ validation rules (country code, ranges, enums) defined once in tools-core.
23
+ """
24
+ try:
25
+ model = schema_cls(**kwargs)
26
+ except ValidationError as exc:
27
+ _stderr.print("[red]Invalid arguments:[/red]")
28
+ for err in exc.errors():
29
+ loc = ".".join(str(part) for part in err["loc"]) or "(value)"
30
+ _stderr.print(f" {loc}: {err['msg']}")
31
+ raise typer.Exit(int(ExitCode.USAGE)) from exc
32
+ return model.model_dump()
33
+
34
+
35
+ def call(ctx: typer.Context, method_name: str, payload: dict[str, Any]) -> NoReturn:
36
+ """Invoke a service method and emit the result envelope.
37
+
38
+ Unexpected (non-``ApiException``) failures — e.g. connection errors — are
39
+ wrapped into an error envelope so output stays uniform; ``--verbose`` lets
40
+ the original traceback through.
41
+ """
42
+ cli = get_cli_context(ctx)
43
+ service = cli.service()
44
+ try:
45
+ result = getattr(service, method_name)(**payload)
46
+ except typer.Exit:
47
+ raise
48
+ except Exception as exc: # noqa: BLE001 - normalise to an error envelope
49
+ if cli.verbose:
50
+ raise
51
+ result = {
52
+ "ok": False,
53
+ "operation": method_name,
54
+ "error": {"status": None, "code": "CLIENT_ERROR", "message": str(exc)},
55
+ }
56
+ emit(cli.output, result)
@@ -0,0 +1,83 @@
1
+ """gscraper — command-line interface for the Geonode Scraper API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from . import __version__
10
+ from .commands.batch import batch_app
11
+ from .commands.config import config_app
12
+ from .commands.crawl import crawl_app
13
+ from .commands.extract import extract
14
+ from .commands.jobs import jobs_app
15
+ from .commands.map import map_app
16
+ from .commands.stats import stats
17
+ from .commands.system import health
18
+ from .context import CLIContext
19
+ from .output import OutputFormat
20
+
21
+ app = typer.Typer(
22
+ name="gscraper",
23
+ help="Command-line interface for the Geonode Scraper API.",
24
+ no_args_is_help=True,
25
+ add_completion=True,
26
+ )
27
+
28
+
29
+ def _version_callback(value: bool) -> None:
30
+ if value:
31
+ typer.echo(f"gscraper {__version__}")
32
+ raise typer.Exit()
33
+
34
+
35
+ def _resolve_output(as_json: bool, as_yaml: bool) -> OutputFormat:
36
+ if as_json and as_yaml:
37
+ raise typer.BadParameter("Use only one of --json or --yaml.")
38
+ if as_json:
39
+ return OutputFormat.JSON
40
+ if as_yaml:
41
+ return OutputFormat.YAML
42
+ return OutputFormat.TABLE
43
+
44
+
45
+ @app.callback()
46
+ def main_callback(
47
+ ctx: typer.Context,
48
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="API key (prefer env/config over the CLI flag)."),
49
+ host: Optional[str] = typer.Option(None, "--host", help="API base URL."),
50
+ profile: Optional[str] = typer.Option(None, "--profile", help="Config profile to use."),
51
+ as_json: bool = typer.Option(False, "--json", help="Output the raw JSON envelope."),
52
+ as_yaml: bool = typer.Option(False, "--yaml", help="Output the raw YAML envelope."),
53
+ verify_ssl: Optional[bool] = typer.Option(None, "--verify-ssl/--no-verify-ssl", help="Verify TLS certificates."),
54
+ timeout: Optional[float] = typer.Option(None, "--timeout", help="Request timeout in seconds."),
55
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show full tracebacks on unexpected errors."),
56
+ _version: bool = typer.Option(
57
+ False, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
58
+ ),
59
+ ) -> None:
60
+ """Resolve global options into the shared CLI context."""
61
+ ctx.obj = CLIContext(
62
+ api_key=api_key,
63
+ host=host,
64
+ profile=profile,
65
+ verify_ssl=verify_ssl,
66
+ timeout=timeout,
67
+ output=_resolve_output(as_json, as_yaml),
68
+ verbose=verbose,
69
+ )
70
+
71
+
72
+ app.command("extract")(extract)
73
+ app.command("stats")(stats)
74
+ app.command("health")(health)
75
+ app.add_typer(jobs_app, name="jobs")
76
+ app.add_typer(batch_app, name="batch")
77
+ app.add_typer(crawl_app, name="crawl")
78
+ app.add_typer(map_app, name="map")
79
+ app.add_typer(config_app, name="config")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ app()
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class OutputFormat(str, Enum):
7
+ TABLE = "table"
8
+ JSON = "json"
9
+ YAML = "yaml"
File without changes
@@ -0,0 +1,49 @@
1
+ """Output dispatch: render a service result envelope and exit with a code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Mapping, NoReturn
7
+
8
+ import typer
9
+ import yaml
10
+ from rich.console import Console
11
+
12
+ from ..errors import ExitCode, exit_code_for_error, format_error
13
+ from ..output import OutputFormat
14
+ from .tables import render_human
15
+
16
+ # rich auto-disables styling when stdout is not a TTY and honours NO_COLOR.
17
+ _stdout = Console()
18
+ _stderr = Console(stderr=True)
19
+
20
+
21
+ def console() -> Console:
22
+ return _stdout
23
+
24
+
25
+ def _to_jsonable(value: Any) -> Any:
26
+ return json.loads(json.dumps(value, default=str))
27
+
28
+
29
+ def emit(output: OutputFormat, result: Mapping[str, Any]) -> NoReturn:
30
+ """Render a service envelope (``{ok, operation, result|error, ...}``) and exit.
31
+
32
+ The JSON/YAML formats print the full envelope so both success and error are
33
+ machine-parseable; the table format prints a human summary or a friendly
34
+ error line on stderr.
35
+ """
36
+ ok = bool(result.get("ok"))
37
+ code = ExitCode.OK if ok else exit_code_for_error(result.get("error") or {})
38
+
39
+ if output is OutputFormat.JSON:
40
+ _stdout.print_json(json.dumps(_to_jsonable(result)))
41
+ elif output is OutputFormat.YAML:
42
+ _stdout.print(yaml.safe_dump(_to_jsonable(result), sort_keys=False).rstrip())
43
+ else:
44
+ if ok:
45
+ render_human(result, _stdout)
46
+ else:
47
+ _stderr.print(f"[red]{format_error(result.get('error') or {})}[/red]")
48
+
49
+ raise typer.Exit(int(code))
@@ -0,0 +1,118 @@
1
+ """Human-friendly rendering of service result payloads.
2
+
3
+ Designed to be defensive: the service returns plain dicts whose shape tracks the
4
+ OpenAPI spec and can change between versions. Renderers use safe access and a
5
+ generic fallback, and ``render_human`` ultimately falls back to pretty JSON for
6
+ any shape it cannot format.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Mapping, Sequence
12
+
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ # Long text fields (markdown/html/raw content) are summarised by length rather
17
+ # than dumped into the terminal.
18
+ _LONG_TEXT_THRESHOLD = 200
19
+
20
+ # Result keys that commonly hold a list of records worth rendering as a table.
21
+ _COLLECTION_KEYS = ("jobs", "items", "links", "results", "data")
22
+
23
+
24
+ def _is_scalar(value: Any) -> bool:
25
+ return value is None or isinstance(value, (str, int, float, bool))
26
+
27
+
28
+ def _summarize_scalar(value: Any) -> str:
29
+ if value is None:
30
+ return "-"
31
+ if isinstance(value, str) and len(value) > _LONG_TEXT_THRESHOLD:
32
+ return f"<{len(value)} chars>"
33
+ return str(value)
34
+
35
+
36
+ def _kv_table(data: Mapping[str, Any], console: Console, *, title: str | None = None) -> None:
37
+ table = Table(show_header=False, box=None, title=title, title_justify="left", pad_edge=False)
38
+ table.add_column("field", style="bold cyan", no_wrap=True)
39
+ table.add_column("value", overflow="fold")
40
+ for key, value in data.items():
41
+ if _is_scalar(value):
42
+ table.add_row(str(key), _summarize_scalar(value))
43
+ elif isinstance(value, Mapping):
44
+ table.add_row(str(key), _summarize_mapping(value))
45
+ elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
46
+ table.add_row(str(key), f"<{len(value)} item(s)>")
47
+ else:
48
+ table.add_row(str(key), str(value))
49
+ console.print(table)
50
+
51
+
52
+ def _summarize_mapping(data: Mapping[str, Any]) -> str:
53
+ parts = []
54
+ for key, value in data.items():
55
+ if _is_scalar(value):
56
+ parts.append(f"{key}={_summarize_scalar(value)}")
57
+ else:
58
+ parts.append(f"{key}=…")
59
+ return ", ".join(parts) if parts else "{}"
60
+
61
+
62
+ def _records_table(records: Sequence[Mapping[str, Any]], console: Console, *, title: str | None = None) -> None:
63
+ # Union of keys across records, preserving first-seen order; scalar columns only.
64
+ columns: list[str] = []
65
+ for record in records:
66
+ if not isinstance(record, Mapping):
67
+ # Heterogeneous / non-dict list: bail to the generic path.
68
+ console.print_json(data=list(records))
69
+ return
70
+ for key, value in record.items():
71
+ if key not in columns and _is_scalar(value):
72
+ columns.append(key)
73
+
74
+ if not columns:
75
+ console.print_json(data=list(records))
76
+ return
77
+
78
+ table = Table(title=title, title_justify="left", header_style="bold cyan")
79
+ for column in columns:
80
+ table.add_column(column, overflow="fold")
81
+ for record in records:
82
+ table.add_row(*[_summarize_scalar(record.get(column)) for column in columns])
83
+ console.print(table)
84
+
85
+
86
+ def _render_generic(data: Any, console: Console) -> None:
87
+ if isinstance(data, Mapping):
88
+ scalars = {k: v for k, v in data.items() if _is_scalar(v) or isinstance(v, Mapping)}
89
+ if scalars:
90
+ _kv_table(scalars, console)
91
+ # Render the first meaningful collection field as its own table.
92
+ for key, value in data.items():
93
+ if (
94
+ isinstance(value, Sequence)
95
+ and not isinstance(value, (str, bytes, bytearray))
96
+ and value
97
+ and (key in _COLLECTION_KEYS or all(isinstance(item, Mapping) for item in value))
98
+ ):
99
+ console.print()
100
+ _records_table(value, console, title=str(key))
101
+ break
102
+ elif isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)):
103
+ _records_table(data, console)
104
+ else:
105
+ console.print(str(data))
106
+
107
+
108
+ def render_human(result: Mapping[str, Any], console: Console) -> None:
109
+ """Render a successful service envelope's ``result`` for human consumption."""
110
+ data = result.get("result")
111
+ if data is None:
112
+ operation = result.get("operation", "operation")
113
+ console.print(f"[green]{operation}: ok[/green]")
114
+ return
115
+ try:
116
+ _render_generic(data, console)
117
+ except Exception: # noqa: BLE001 - unfamiliar shape: never crash on rendering
118
+ console.print_json(data=data)
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: geonode-scraper-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for the Geonode Scraper API
5
+ Author: Geonode Team
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: geonode-scraper-tools-core>=0.3.1
14
+ Requires-Dist: typer>=0.12
15
+ Requires-Dist: rich>=13.0
16
+ Requires-Dist: PyYAML>=6.0
17
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == "dev"
20
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
21
+ Requires-Dist: ruff>=0.12.11; extra == "dev"
22
+
23
+ # Geonode Scraper CLI
24
+
25
+ `gscraper` is the command-line interface for the Geonode Scraper API. It is a thin
26
+ presentation layer over [`geonode-scraper-tools-core`](../python-agent-tools-core):
27
+ commands parse flags, resolve configuration, call a stable service method, and
28
+ render the result. All domain logic (validation, polling, retries) lives in the
29
+ service layer, not in the CLI.
30
+
31
+ ## Requirements
32
+
33
+ - Python 3.10+
34
+ - Works on **Linux**, **macOS**, and **Windows**
35
+
36
+ ## Installation
37
+
38
+ **Recommended — install as a standalone tool with [pipx](https://pipx.pypa.io):**
39
+
40
+ ```sh
41
+ pipx install geonode-scraper-cli
42
+ ```
43
+
44
+ `pipx` installs `gscraper` into its own isolated virtual environment and puts it
45
+ on your `PATH`, so it never conflicts with other Python projects. This is the
46
+ preferred way to install CLI tools globally.
47
+
48
+ **Alternative — install with pip:**
49
+
50
+ ```sh
51
+ pip install geonode-scraper-cli
52
+ ```
53
+
54
+ > **Windows note:** on Windows the `gscraper` command is placed in the Python
55
+ > Scripts folder (e.g. `%APPDATA%\Python\Python3xx\Scripts`). If the command is
56
+ > not found after installation, add that folder to your `PATH`, or use
57
+ > `python -m geonode_scraper_cli` as a fallback. `pipx` handles this
58
+ > automatically and is the simpler choice on Windows.
59
+
60
+ ## Configuration
61
+
62
+ Configuration is resolved with the following precedence (highest first):
63
+
64
+ 1. Command-line flags (`--api-key`, `--host`, ...)
65
+ 2. Environment variables (`GEONODE_SCRAPER_API_KEY`, `GEONODE_SCRAPER_HOST`,
66
+ `GEONODE_SCRAPER_VERIFY_SSL`, `GEONODE_SCRAPER_TIMEOUT`, `GEONODE_SCRAPER_PROFILE`)
67
+ 3. A TOML config file at `~/.config/geonode-scraper/config.toml`
68
+ 4. Built-in defaults
69
+
70
+ Prefer environment variables or the config file for your API key — passing
71
+ `--api-key` on the command line can leak it into your shell history.
72
+
73
+ Example `~/.config/geonode-scraper/config.toml`:
74
+
75
+ ```toml
76
+ [default]
77
+ host = "https://api.example.com"
78
+ api_key = "your-api-key"
79
+ verify_ssl = true
80
+
81
+ [staging]
82
+ host = "https://staging.example.com"
83
+ api_key = "your-staging-key"
84
+ ```
85
+
86
+ Select a non-default profile with `--profile staging` or
87
+ `GEONODE_SCRAPER_PROFILE=staging`. Inspect the active configuration with:
88
+
89
+ ```sh
90
+ gscraper config path # print the config file location
91
+ gscraper config show # show profiles (API keys masked)
92
+ ```
93
+
94
+ ## Output
95
+
96
+ Commands print a human-readable summary by default. Use `--json` or `--yaml`
97
+ to print the raw result envelope for scripting. These flags can appear either
98
+ before the subcommand (global position) or after it (per-command position) —
99
+ both work:
100
+
101
+ ```sh
102
+ gscraper extract https://example.com --json | jq -r .result.data.markdown
103
+ gscraper --json extract https://example.com | jq -r .result.data.markdown
104
+ ```
105
+
106
+ The JSON/YAML envelope has the shape `{ "ok": bool, "operation": str, "result": {...} }`
107
+ on success, or `{ "ok": false, "operation": str, "error": {...} }` on failure.
108
+
109
+ ## Commands
110
+
111
+ ```text
112
+ gscraper extract URL [--format markdown|html] [--render-js] [--async] \
113
+ [--proxy-country US] [--proxy-type residential] \
114
+ [--header "K: V"] [--output out.md]
115
+
116
+ gscraper jobs get JOB_ID
117
+ gscraper jobs list [--status completed] [--url ...] [--page N]
118
+ gscraper jobs wait JOB_ID [--timeout S] [--interval S]
119
+
120
+ gscraper batch create URL [URL ...] [--format markdown]
121
+ gscraper batch status JOB_ID
122
+ gscraper batch wait JOB_ID [--timeout S] [--interval S]
123
+ gscraper batch list [--status ...]
124
+ gscraper batch cancel JOB_ID
125
+
126
+ gscraper crawl create URL [--depth 2] [--limit 50] [--include-subdomains]
127
+ gscraper crawl status JOB_ID
128
+ gscraper crawl wait JOB_ID
129
+ gscraper crawl list [--url ...]
130
+ gscraper crawl cancel JOB_ID
131
+
132
+ gscraper map run URL [--search term] [--no-subdomains] # primary action
133
+ gscraper map jobs list # inspect past map jobs
134
+ gscraper map jobs get JOB_ID
135
+
136
+ gscraper stats [--start-date ISO] [--end-date ISO]
137
+ gscraper health
138
+ ```
139
+
140
+ Run `gscraper --help` or `gscraper <command> --help` for full details.
141
+
142
+ ## Exit codes
143
+
144
+ | Code | Meaning |
145
+ | ---- | ------- |
146
+ | 0 | Success |
147
+ | 1 | Generic error |
148
+ | 2 | Usage / invalid arguments |
149
+ | 4 | Authentication / authorization (401, 403) |
150
+ | 5 | Not found (404) |
151
+ | 6 | Validation error (422) |
152
+ | 7 | Network / connection error |
153
+ | 8 | Polling timeout (`wait` commands) |
154
+
155
+ ## Shell completion
156
+
157
+ ```sh
158
+ gscraper --install-completion
159
+ ```