webextrator-cli 2026.7.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.
- webextrator_cli/__init__.py +3 -0
- webextrator_cli/__main__.py +6 -0
- webextrator_cli/commands/__init__.py +1 -0
- webextrator_cli/commands/info.py +25 -0
- webextrator_cli/commands/task.py +101 -0
- webextrator_cli/commands/web.py +252 -0
- webextrator_cli/core/__init__.py +1 -0
- webextrator_cli/core/client.py +103 -0
- webextrator_cli/core/config.py +39 -0
- webextrator_cli/core/exceptions.py +37 -0
- webextrator_cli/core/output.py +114 -0
- webextrator_cli/main.py +65 -0
- webextrator_cli-2026.7.1.0.dist-info/METADATA +97 -0
- webextrator_cli-2026.7.1.0.dist-info/RECORD +17 -0
- webextrator_cli-2026.7.1.0.dist-info/WHEEL +4 -0
- webextrator_cli-2026.7.1.0.dist-info/entry_points.txt +3 -0
- webextrator_cli-2026.7.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""WebExtrator CLI commands package."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Info and utility commands."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from webextrator_cli.core.config import settings
|
|
6
|
+
from webextrator_cli.core.output import console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command()
|
|
10
|
+
def config() -> None:
|
|
11
|
+
"""Show current configuration."""
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
table = Table(title="WebExtrator CLI Configuration")
|
|
15
|
+
table.add_column("Setting", style="bold cyan")
|
|
16
|
+
table.add_column("Value")
|
|
17
|
+
|
|
18
|
+
table.add_row("API Base URL", settings.api_base_url)
|
|
19
|
+
table.add_row(
|
|
20
|
+
"API Token",
|
|
21
|
+
f"{settings.api_token[:8]}..." if settings.api_token else "[red]Not set[/red]",
|
|
22
|
+
)
|
|
23
|
+
table.add_row("Request Timeout", f"{settings.request_timeout}s")
|
|
24
|
+
|
|
25
|
+
console.print(table)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Task management commands for WebExtrator."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from webextrator_cli.core.client import get_client
|
|
6
|
+
from webextrator_cli.core.exceptions import WebExtratorError
|
|
7
|
+
from webextrator_cli.core.output import print_error, print_json, print_task_result
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def tasks() -> None:
|
|
12
|
+
"""Query previously created render/extract tasks.
|
|
13
|
+
|
|
14
|
+
\b
|
|
15
|
+
Examples:
|
|
16
|
+
webextrator tasks retrieve --id 550e8400-e29b-41d4-a716-446655440000
|
|
17
|
+
webextrator tasks retrieve --trace-id 550e8400-e29b-41d4-a716-446655440001
|
|
18
|
+
webextrator tasks batch --ids id1 id2
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@tasks.command()
|
|
23
|
+
@click.option("--id", "task_id", default=None, help="Task UUID to retrieve.")
|
|
24
|
+
@click.option("--trace-id", default=None, help="Trace UUID to retrieve.")
|
|
25
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
|
|
26
|
+
@click.pass_context
|
|
27
|
+
def retrieve(
|
|
28
|
+
ctx: click.Context,
|
|
29
|
+
task_id: str | None,
|
|
30
|
+
trace_id: str | None,
|
|
31
|
+
output_json: bool,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Retrieve a single render/extract task by ID or trace ID.
|
|
34
|
+
|
|
35
|
+
\b
|
|
36
|
+
Examples:
|
|
37
|
+
webextrator tasks retrieve --id 550e8400-e29b-41d4-a716-446655440000
|
|
38
|
+
webextrator tasks retrieve --trace-id 550e8400-e29b-41d4-a716-446655440001
|
|
39
|
+
"""
|
|
40
|
+
if not task_id and not trace_id:
|
|
41
|
+
raise click.UsageError("Provide at least one of --id or --trace-id.")
|
|
42
|
+
|
|
43
|
+
client = get_client(ctx.obj.get("token"))
|
|
44
|
+
payload: dict[str, object] = {
|
|
45
|
+
"action": "retrieve",
|
|
46
|
+
"id": task_id,
|
|
47
|
+
"trace_id": trace_id,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
result = client.tasks(**payload) # type: ignore[arg-type]
|
|
52
|
+
if output_json:
|
|
53
|
+
print_json(result)
|
|
54
|
+
else:
|
|
55
|
+
print_task_result(result)
|
|
56
|
+
except WebExtratorError as e:
|
|
57
|
+
print_error(e.message)
|
|
58
|
+
raise SystemExit(1) from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@tasks.command()
|
|
62
|
+
@click.option("--ids", multiple=True, help="Task UUIDs to retrieve (repeatable).")
|
|
63
|
+
@click.option("--trace-ids", multiple=True, help="Trace UUIDs to retrieve (repeatable).")
|
|
64
|
+
@click.option("--offset", default=None, type=int, help="Pagination offset (default 0).")
|
|
65
|
+
@click.option("--limit", default=None, type=int, help="Page size (default 12).")
|
|
66
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def batch(
|
|
69
|
+
ctx: click.Context,
|
|
70
|
+
ids: tuple[str, ...],
|
|
71
|
+
trace_ids: tuple[str, ...],
|
|
72
|
+
offset: int | None,
|
|
73
|
+
limit: int | None,
|
|
74
|
+
output_json: bool,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Retrieve multiple render/extract tasks at once.
|
|
77
|
+
|
|
78
|
+
\b
|
|
79
|
+
Examples:
|
|
80
|
+
webextrator tasks batch --ids id1 id2
|
|
81
|
+
webextrator tasks batch --trace-ids trace-001 trace-002
|
|
82
|
+
webextrator tasks batch --limit 5
|
|
83
|
+
"""
|
|
84
|
+
client = get_client(ctx.obj.get("token"))
|
|
85
|
+
payload: dict[str, object] = {
|
|
86
|
+
"action": "retrieve_batch",
|
|
87
|
+
"ids": list(ids) if ids else None,
|
|
88
|
+
"trace_ids": list(trace_ids) if trace_ids else None,
|
|
89
|
+
"offset": offset,
|
|
90
|
+
"limit": limit,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
result = client.tasks(**payload) # type: ignore[arg-type]
|
|
95
|
+
if output_json:
|
|
96
|
+
print_json(result)
|
|
97
|
+
else:
|
|
98
|
+
print_task_result(result)
|
|
99
|
+
except WebExtratorError as e:
|
|
100
|
+
print_error(e.message)
|
|
101
|
+
raise SystemExit(1) from e
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Web extraction and rendering commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from webextrator_cli.core.client import get_client
|
|
8
|
+
from webextrator_cli.core.exceptions import WebExtratorError
|
|
9
|
+
from webextrator_cli.core.output import (
|
|
10
|
+
WAIT_UNTIL_OPTIONS,
|
|
11
|
+
print_error,
|
|
12
|
+
print_extract_result,
|
|
13
|
+
print_json,
|
|
14
|
+
print_render_result,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_headers(headers: str | None) -> dict[str, str] | None:
|
|
19
|
+
"""Parse --headers JSON option."""
|
|
20
|
+
if headers is None:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
parsed = json.loads(headers)
|
|
24
|
+
except json.JSONDecodeError as exc:
|
|
25
|
+
raise click.BadParameter("--headers must be a valid JSON object.") from exc
|
|
26
|
+
if not isinstance(parsed, dict) or not all(
|
|
27
|
+
isinstance(k, str) and isinstance(v, str) for k, v in parsed.items()
|
|
28
|
+
):
|
|
29
|
+
raise click.BadParameter("--headers must be a JSON object of string key/value pairs.")
|
|
30
|
+
return parsed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.command()
|
|
34
|
+
@click.argument("url")
|
|
35
|
+
@click.option(
|
|
36
|
+
"--expected-type",
|
|
37
|
+
type=click.Choice(["product", "article", "general"]),
|
|
38
|
+
default=None,
|
|
39
|
+
help="Hint about the expected page type to optimize extraction.",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--enable-llm",
|
|
43
|
+
is_flag=True,
|
|
44
|
+
default=False,
|
|
45
|
+
help="Enable LLM-based semantic normalization as a final extraction step.",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--wait-until",
|
|
49
|
+
type=click.Choice(WAIT_UNTIL_OPTIONS),
|
|
50
|
+
default=None,
|
|
51
|
+
help="Page load wait condition (default: networkidle).",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--timeout",
|
|
55
|
+
default=None,
|
|
56
|
+
type=float,
|
|
57
|
+
help="Total timeout in seconds for the extract operation (default: 30).",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--delay",
|
|
61
|
+
default=None,
|
|
62
|
+
type=float,
|
|
63
|
+
help="Extra delay in seconds after the page is loaded, before extraction.",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--wait-for-selector",
|
|
67
|
+
default=None,
|
|
68
|
+
help="CSS selector to wait for before starting extraction.",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--block-resources/--no-block-resources",
|
|
72
|
+
default=None,
|
|
73
|
+
help="Block non-essential resources (images/fonts/media) during page load.",
|
|
74
|
+
)
|
|
75
|
+
@click.option(
|
|
76
|
+
"--headers",
|
|
77
|
+
default=None,
|
|
78
|
+
help='Custom request headers as JSON, e.g. \'{"Accept-Language":"en-US"}\'.',
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--user-agent",
|
|
82
|
+
default=None,
|
|
83
|
+
help="Override the User-Agent header.",
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--callback-url",
|
|
87
|
+
default=None,
|
|
88
|
+
help="Callback URL for async processing.",
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
"--async",
|
|
92
|
+
"async_mode",
|
|
93
|
+
is_flag=True,
|
|
94
|
+
default=False,
|
|
95
|
+
help="Submit asynchronously; returns a task_id to poll instead of waiting.",
|
|
96
|
+
)
|
|
97
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
|
|
98
|
+
@click.pass_context
|
|
99
|
+
def extract(
|
|
100
|
+
ctx: click.Context,
|
|
101
|
+
url: str,
|
|
102
|
+
expected_type: str | None,
|
|
103
|
+
enable_llm: bool,
|
|
104
|
+
wait_until: str | None,
|
|
105
|
+
timeout: float | None,
|
|
106
|
+
delay: float | None,
|
|
107
|
+
wait_for_selector: str | None,
|
|
108
|
+
block_resources: bool | None,
|
|
109
|
+
headers: str | None,
|
|
110
|
+
user_agent: str | None,
|
|
111
|
+
callback_url: str | None,
|
|
112
|
+
async_mode: bool,
|
|
113
|
+
output_json: bool,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Extract structured content from a web page.
|
|
116
|
+
|
|
117
|
+
URL is the address of the web page to extract content from.
|
|
118
|
+
|
|
119
|
+
\b
|
|
120
|
+
Examples:
|
|
121
|
+
webextrator extract https://www.amazon.com/dp/B0C1234567
|
|
122
|
+
webextrator extract https://example.com/article --expected-type article
|
|
123
|
+
webextrator extract https://shop.example.com --enable-llm
|
|
124
|
+
"""
|
|
125
|
+
client = get_client(ctx.obj.get("token"))
|
|
126
|
+
payload: dict[str, object] = {
|
|
127
|
+
"url": url,
|
|
128
|
+
"expected_type": expected_type,
|
|
129
|
+
"enable_llm": enable_llm if enable_llm else None,
|
|
130
|
+
"wait_until": wait_until,
|
|
131
|
+
"timeout": timeout,
|
|
132
|
+
"delay": delay,
|
|
133
|
+
"wait_for_selector": wait_for_selector,
|
|
134
|
+
"block_resources": block_resources,
|
|
135
|
+
"headers": _parse_headers(headers),
|
|
136
|
+
"user_agent": user_agent,
|
|
137
|
+
"callback_url": callback_url,
|
|
138
|
+
"async": async_mode,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
result = client.extract(**payload) # type: ignore[arg-type]
|
|
143
|
+
if output_json:
|
|
144
|
+
print_json(result)
|
|
145
|
+
else:
|
|
146
|
+
print_extract_result(result)
|
|
147
|
+
except WebExtratorError as e:
|
|
148
|
+
print_error(e.message)
|
|
149
|
+
raise SystemExit(1) from e
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@click.command()
|
|
153
|
+
@click.argument("url")
|
|
154
|
+
@click.option(
|
|
155
|
+
"--wait-until",
|
|
156
|
+
type=click.Choice(WAIT_UNTIL_OPTIONS),
|
|
157
|
+
default=None,
|
|
158
|
+
help="Page load wait condition (default: networkidle).",
|
|
159
|
+
)
|
|
160
|
+
@click.option(
|
|
161
|
+
"--timeout",
|
|
162
|
+
default=None,
|
|
163
|
+
type=float,
|
|
164
|
+
help="Total timeout in seconds for the render operation (default: 30).",
|
|
165
|
+
)
|
|
166
|
+
@click.option(
|
|
167
|
+
"--delay",
|
|
168
|
+
default=None,
|
|
169
|
+
type=float,
|
|
170
|
+
help="Extra delay in seconds after the page is loaded, before HTML is captured.",
|
|
171
|
+
)
|
|
172
|
+
@click.option(
|
|
173
|
+
"--wait-for-selector",
|
|
174
|
+
default=None,
|
|
175
|
+
help="CSS selector to wait for before capturing HTML.",
|
|
176
|
+
)
|
|
177
|
+
@click.option(
|
|
178
|
+
"--block-resources/--no-block-resources",
|
|
179
|
+
default=None,
|
|
180
|
+
help="Block non-essential resources (images/fonts/media) during page load.",
|
|
181
|
+
)
|
|
182
|
+
@click.option(
|
|
183
|
+
"--headers",
|
|
184
|
+
default=None,
|
|
185
|
+
help='Custom request headers as JSON, e.g. \'{"Accept-Language":"en-US"}\'.',
|
|
186
|
+
)
|
|
187
|
+
@click.option(
|
|
188
|
+
"--user-agent",
|
|
189
|
+
default=None,
|
|
190
|
+
help="Override the User-Agent header.",
|
|
191
|
+
)
|
|
192
|
+
@click.option(
|
|
193
|
+
"--callback-url",
|
|
194
|
+
default=None,
|
|
195
|
+
help="Callback URL for async processing.",
|
|
196
|
+
)
|
|
197
|
+
@click.option(
|
|
198
|
+
"--async",
|
|
199
|
+
"async_mode",
|
|
200
|
+
is_flag=True,
|
|
201
|
+
default=False,
|
|
202
|
+
help="Submit asynchronously; returns a task_id to poll instead of waiting.",
|
|
203
|
+
)
|
|
204
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
|
|
205
|
+
@click.pass_context
|
|
206
|
+
def render(
|
|
207
|
+
ctx: click.Context,
|
|
208
|
+
url: str,
|
|
209
|
+
wait_until: str | None,
|
|
210
|
+
timeout: float | None,
|
|
211
|
+
delay: float | None,
|
|
212
|
+
wait_for_selector: str | None,
|
|
213
|
+
block_resources: bool | None,
|
|
214
|
+
headers: str | None,
|
|
215
|
+
user_agent: str | None,
|
|
216
|
+
callback_url: str | None,
|
|
217
|
+
async_mode: bool,
|
|
218
|
+
output_json: bool,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Render a web page and return the rendered HTML.
|
|
221
|
+
|
|
222
|
+
URL is the address of the web page to render.
|
|
223
|
+
|
|
224
|
+
\b
|
|
225
|
+
Examples:
|
|
226
|
+
webextrator render https://example.com
|
|
227
|
+
webextrator render https://example.com --wait-until load
|
|
228
|
+
webextrator render https://spa.example.com --delay 2 --wait-for-selector "#app"
|
|
229
|
+
"""
|
|
230
|
+
client = get_client(ctx.obj.get("token"))
|
|
231
|
+
payload: dict[str, object] = {
|
|
232
|
+
"url": url,
|
|
233
|
+
"wait_until": wait_until,
|
|
234
|
+
"timeout": timeout,
|
|
235
|
+
"delay": delay,
|
|
236
|
+
"wait_for_selector": wait_for_selector,
|
|
237
|
+
"block_resources": block_resources,
|
|
238
|
+
"headers": _parse_headers(headers),
|
|
239
|
+
"user_agent": user_agent,
|
|
240
|
+
"callback_url": callback_url,
|
|
241
|
+
"async": async_mode,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
result = client.render(**payload) # type: ignore[arg-type]
|
|
246
|
+
if output_json:
|
|
247
|
+
print_json(result)
|
|
248
|
+
else:
|
|
249
|
+
print_render_result(result)
|
|
250
|
+
except WebExtratorError as e:
|
|
251
|
+
print_error(e.message)
|
|
252
|
+
raise SystemExit(1) from e
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""WebExtrator CLI core package."""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""HTTP client for WebExtrator API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from webextrator_cli.core.config import settings
|
|
8
|
+
from webextrator_cli.core.exceptions import (
|
|
9
|
+
WebExtratorAPIError,
|
|
10
|
+
WebExtratorAuthError,
|
|
11
|
+
WebExtratorTimeoutError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebExtratorClient:
|
|
16
|
+
"""HTTP client for AceDataCloud WebExtrator API."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, api_token: str | None = None, base_url: str | None = None):
|
|
19
|
+
self.api_token = api_token if api_token is not None else settings.api_token
|
|
20
|
+
self.base_url = base_url or settings.api_base_url
|
|
21
|
+
self.timeout = settings.request_timeout
|
|
22
|
+
|
|
23
|
+
def _get_headers(self) -> dict[str, str]:
|
|
24
|
+
"""Get request headers with authentication."""
|
|
25
|
+
if not self.api_token:
|
|
26
|
+
raise WebExtratorAuthError(
|
|
27
|
+
"API token not configured. Set ACEDATACLOUD_API_TOKEN or use --token option."
|
|
28
|
+
)
|
|
29
|
+
return {
|
|
30
|
+
"accept": "application/json",
|
|
31
|
+
"authorization": f"Bearer {self.api_token}",
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def request(
|
|
36
|
+
self,
|
|
37
|
+
endpoint: str,
|
|
38
|
+
payload: dict[str, Any],
|
|
39
|
+
timeout: float | None = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Make a POST request to the WebExtrator API."""
|
|
42
|
+
url = f"{self.base_url}{endpoint}"
|
|
43
|
+
request_timeout = timeout or self.timeout
|
|
44
|
+
|
|
45
|
+
# Remove None values from payload
|
|
46
|
+
payload = {k: v for k, v in payload.items() if v is not None}
|
|
47
|
+
|
|
48
|
+
with httpx.Client() as http_client:
|
|
49
|
+
try:
|
|
50
|
+
response = http_client.post(
|
|
51
|
+
url,
|
|
52
|
+
json=payload,
|
|
53
|
+
headers=self._get_headers(),
|
|
54
|
+
timeout=request_timeout,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if response.status_code == 401:
|
|
58
|
+
raise WebExtratorAuthError("Invalid API token")
|
|
59
|
+
|
|
60
|
+
if response.status_code == 403:
|
|
61
|
+
raise WebExtratorAuthError("Access denied. Check your API permissions.")
|
|
62
|
+
|
|
63
|
+
response.raise_for_status()
|
|
64
|
+
return response.json() # type: ignore[no-any-return]
|
|
65
|
+
|
|
66
|
+
except httpx.TimeoutException as e:
|
|
67
|
+
raise WebExtratorTimeoutError(
|
|
68
|
+
f"Request to {endpoint} timed out after {request_timeout}s"
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
except WebExtratorAuthError:
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
except httpx.HTTPStatusError as e:
|
|
75
|
+
raise WebExtratorAPIError(
|
|
76
|
+
message=e.response.text,
|
|
77
|
+
code=f"http_{e.response.status_code}",
|
|
78
|
+
status_code=e.response.status_code,
|
|
79
|
+
) from e
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
if isinstance(e, WebExtratorAPIError | WebExtratorTimeoutError):
|
|
83
|
+
raise
|
|
84
|
+
raise WebExtratorAPIError(message=str(e)) from e
|
|
85
|
+
|
|
86
|
+
def extract(self, **kwargs: Any) -> dict[str, Any]:
|
|
87
|
+
"""Extract structured content from a web page."""
|
|
88
|
+
return self.request("/webextrator/extract", kwargs)
|
|
89
|
+
|
|
90
|
+
def render(self, **kwargs: Any) -> dict[str, Any]:
|
|
91
|
+
"""Render a web page and return the HTML."""
|
|
92
|
+
return self.request("/webextrator/render", kwargs)
|
|
93
|
+
|
|
94
|
+
def tasks(self, **kwargs: Any) -> dict[str, Any]:
|
|
95
|
+
"""Query previously created render/extract tasks."""
|
|
96
|
+
return self.request("/webextrator/tasks", kwargs)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_client(token: str | None = None) -> WebExtratorClient:
|
|
100
|
+
"""Get a WebExtratorClient instance, optionally overriding the token."""
|
|
101
|
+
if token:
|
|
102
|
+
return WebExtratorClient(api_token=token)
|
|
103
|
+
return WebExtratorClient()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Configuration management for WebExtrator CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Settings:
|
|
13
|
+
"""Application settings loaded from environment variables."""
|
|
14
|
+
|
|
15
|
+
api_base_url: str = field(
|
|
16
|
+
default_factory=lambda: os.environ.get(
|
|
17
|
+
"ACEDATACLOUD_API_BASE_URL", "https://api.acedata.cloud"
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
api_token: str = field(default_factory=lambda: os.environ.get("ACEDATACLOUD_API_TOKEN", ""))
|
|
21
|
+
request_timeout: float = field(
|
|
22
|
+
default_factory=lambda: float(os.environ.get("WEBEXTRATOR_REQUEST_TIMEOUT", "60"))
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_configured(self) -> bool:
|
|
27
|
+
"""Check if the API token is configured."""
|
|
28
|
+
return bool(self.api_token)
|
|
29
|
+
|
|
30
|
+
def validate(self) -> None:
|
|
31
|
+
"""Validate configuration. Raises ValueError if API token is missing."""
|
|
32
|
+
if not self.api_token:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"API token not configured. "
|
|
35
|
+
"Set ACEDATACLOUD_API_TOKEN environment variable or use --token option."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
settings = Settings()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Custom exceptions for WebExtrator CLI."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WebExtratorError(Exception):
|
|
5
|
+
"""Base exception for WebExtrator CLI."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, code: str = "unknown"):
|
|
8
|
+
self.message = message
|
|
9
|
+
self.code = code
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WebExtratorAuthError(WebExtratorError):
|
|
14
|
+
"""Authentication error."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Authentication failed"):
|
|
17
|
+
super().__init__(message, code="auth_error")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebExtratorAPIError(WebExtratorError):
|
|
21
|
+
"""API error with HTTP status code."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str = "API request failed",
|
|
26
|
+
code: str = "api_error",
|
|
27
|
+
status_code: int | None = None,
|
|
28
|
+
):
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
super().__init__(message, code)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebExtratorTimeoutError(WebExtratorError):
|
|
34
|
+
"""Request timeout error."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str = "Request timed out"):
|
|
37
|
+
super().__init__(message, code="timeout_error")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Rich terminal output formatting for WebExtrator CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
# Wait-until options
|
|
14
|
+
WAIT_UNTIL_OPTIONS = ["load", "domcontentloaded", "networkidle", "commit"]
|
|
15
|
+
|
|
16
|
+
# Block resource types
|
|
17
|
+
BLOCK_RESOURCE_TYPES = ["image", "font", "media", "stylesheet", "xhr", "fetch"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_json(data: Any) -> None:
|
|
21
|
+
"""Print data as formatted JSON."""
|
|
22
|
+
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def print_error(message: str) -> None:
|
|
26
|
+
"""Print an error message."""
|
|
27
|
+
console.print(f"[bold red]Error:[/bold red] {message}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def print_success(message: str) -> None:
|
|
31
|
+
"""Print a success message."""
|
|
32
|
+
console.print(f"[bold green]\u2713[/bold green] {message}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_extract_result(data: dict[str, Any]) -> None:
|
|
36
|
+
"""Print an extract result in a rich format."""
|
|
37
|
+
task_id = data.get("task_id", "N/A")
|
|
38
|
+
trace_id = data.get("trace_id", "N/A")
|
|
39
|
+
elapsed = data.get("elapsed", "N/A")
|
|
40
|
+
result_data = data.get("data", {})
|
|
41
|
+
|
|
42
|
+
console.print(
|
|
43
|
+
Panel(
|
|
44
|
+
f"[bold]Task ID:[/bold] {task_id}\n"
|
|
45
|
+
f"[bold]Trace ID:[/bold] {trace_id}\n"
|
|
46
|
+
f"[bold]Elapsed:[/bold] {elapsed}s",
|
|
47
|
+
title="[bold green]Extract Result[/bold green]",
|
|
48
|
+
border_style="green",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if result_data and isinstance(result_data, dict):
|
|
53
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
54
|
+
table.add_column("Field", style="bold cyan", width=15)
|
|
55
|
+
table.add_column("Value")
|
|
56
|
+
for key in ["url", "contentType", "title", "description", "siteName"]:
|
|
57
|
+
if result_data.get(key):
|
|
58
|
+
table.add_row(key, str(result_data[key]))
|
|
59
|
+
console.print(table)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_render_result(data: dict[str, Any]) -> None:
|
|
63
|
+
"""Print a render result in a rich format."""
|
|
64
|
+
task_id = data.get("task_id", "N/A")
|
|
65
|
+
trace_id = data.get("trace_id", "N/A")
|
|
66
|
+
elapsed = data.get("elapsed", "N/A")
|
|
67
|
+
result_data = data.get("data", {})
|
|
68
|
+
|
|
69
|
+
console.print(
|
|
70
|
+
Panel(
|
|
71
|
+
f"[bold]Task ID:[/bold] {task_id}\n"
|
|
72
|
+
f"[bold]Trace ID:[/bold] {trace_id}\n"
|
|
73
|
+
f"[bold]Elapsed:[/bold] {elapsed}s",
|
|
74
|
+
title="[bold green]Render Result[/bold green]",
|
|
75
|
+
border_style="green",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if result_data and isinstance(result_data, dict):
|
|
80
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
81
|
+
table.add_column("Field", style="bold cyan", width=15)
|
|
82
|
+
table.add_column("Value")
|
|
83
|
+
for key in ["url", "finalUrl", "title", "status"]:
|
|
84
|
+
if result_data.get(key) is not None:
|
|
85
|
+
table.add_row(key, str(result_data[key]))
|
|
86
|
+
html = result_data.get("html", "")
|
|
87
|
+
if html:
|
|
88
|
+
table.add_row("HTML length", f"{len(html)} chars")
|
|
89
|
+
console.print(table)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_task_result(data: dict[str, Any]) -> None:
|
|
93
|
+
"""Print task query result in a rich format."""
|
|
94
|
+
items = data.get("items")
|
|
95
|
+
if items is not None:
|
|
96
|
+
# Batch result
|
|
97
|
+
count = data.get("count", len(items))
|
|
98
|
+
console.print(f"[bold]Tasks found:[/bold] {count}")
|
|
99
|
+
for item in items:
|
|
100
|
+
_print_single_task(item)
|
|
101
|
+
else:
|
|
102
|
+
_print_single_task(data)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _print_single_task(task_data: dict[str, Any]) -> None:
|
|
106
|
+
"""Print a single task row."""
|
|
107
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
108
|
+
table.add_column("Field", style="bold cyan", width=15)
|
|
109
|
+
table.add_column("Value")
|
|
110
|
+
for key in ["id", "task_id", "trace_id", "type", "started_at", "finished_at", "elapsed"]:
|
|
111
|
+
if task_data.get(key) is not None:
|
|
112
|
+
table.add_row(key.replace("_", " ").title(), str(task_data[key]))
|
|
113
|
+
console.print(table)
|
|
114
|
+
console.print()
|
webextrator_cli/main.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
WebExtrator CLI - Web Render & Extract via AceDataCloud.
|
|
4
|
+
|
|
5
|
+
A command-line tool for extracting structured content and rendering web pages
|
|
6
|
+
powered by AceDataCloud.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
from webextrator_cli.commands.info import config
|
|
15
|
+
from webextrator_cli.commands.task import tasks
|
|
16
|
+
from webextrator_cli.commands.web import extract, render
|
|
17
|
+
|
|
18
|
+
load_dotenv()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_version() -> str:
|
|
22
|
+
"""Get the package version."""
|
|
23
|
+
try:
|
|
24
|
+
return metadata.version("webextrator-cli")
|
|
25
|
+
except metadata.PackageNotFoundError:
|
|
26
|
+
return "dev"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group()
|
|
30
|
+
@click.version_option(version=get_version(), prog_name="webextrator-cli")
|
|
31
|
+
@click.option(
|
|
32
|
+
"--token",
|
|
33
|
+
envvar="ACEDATACLOUD_API_TOKEN",
|
|
34
|
+
help="API token (or set ACEDATACLOUD_API_TOKEN env var).",
|
|
35
|
+
)
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def cli(ctx: click.Context, token: str | None) -> None:
|
|
38
|
+
"""WebExtrator CLI - Web Render & Extract via AceDataCloud.
|
|
39
|
+
|
|
40
|
+
Extract structured data and render web pages from the command line.
|
|
41
|
+
|
|
42
|
+
Get your API token at https://platform.acedata.cloud
|
|
43
|
+
|
|
44
|
+
\b
|
|
45
|
+
Examples:
|
|
46
|
+
webextrator extract https://www.amazon.com/dp/B0C1234567
|
|
47
|
+
webextrator render https://example.com
|
|
48
|
+
webextrator tasks retrieve --id <task-id>
|
|
49
|
+
|
|
50
|
+
Set your token:
|
|
51
|
+
export ACEDATACLOUD_API_TOKEN=your_token
|
|
52
|
+
"""
|
|
53
|
+
ctx.ensure_object(dict)
|
|
54
|
+
ctx.obj["token"] = token
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Register commands
|
|
58
|
+
cli.add_command(extract)
|
|
59
|
+
cli.add_command(render)
|
|
60
|
+
cli.add_command(tasks)
|
|
61
|
+
cli.add_command(config)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
cli()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: webextrator-cli
|
|
3
|
+
Version: 2026.7.1.0
|
|
4
|
+
Summary: CLI tool for WebExtrator Web Render & Extract via AceDataCloud
|
|
5
|
+
Project-URL: Homepage, https://github.com/AceDataCloud/Clis
|
|
6
|
+
Project-URL: Repository, https://github.com/AceDataCloud/Clis
|
|
7
|
+
Project-URL: Issues, https://github.com/AceDataCloud/Clis/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/AceDataCloud/Clis/blob/main/webextrator/CHANGELOG.md
|
|
9
|
+
Author-email: AceDataCloud <support@acedata.cloud>
|
|
10
|
+
Maintainer-email: AceDataCloud <support@acedata.cloud>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: acedata,cli,command-line,web-rendering,web-scraping,webextrator
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: click>=8.1.0
|
|
27
|
+
Requires-Dist: httpx>=0.27.0
|
|
28
|
+
Requires-Dist: pydantic>=2.0.0
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
30
|
+
Requires-Dist: rich>=13.0.0
|
|
31
|
+
Provides-Extra: all
|
|
32
|
+
Requires-Dist: webextrator-cli[dev,release,test]; extra == 'all'
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pre-commit>=3.7.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
37
|
+
Provides-Extra: release
|
|
38
|
+
Requires-Dist: build>=1.2.0; extra == 'release'
|
|
39
|
+
Requires-Dist: twine>=6.1.0; extra == 'release'
|
|
40
|
+
Provides-Extra: test
|
|
41
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
|
|
42
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'test'
|
|
43
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
44
|
+
Requires-Dist: respx>=0.21.0; extra == 'test'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# WebExtrator CLI
|
|
48
|
+
|
|
49
|
+
A command-line tool for web rendering and extraction via [AceDataCloud](https://platform.acedata.cloud).
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install webextrator-cli
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export ACEDATACLOUD_API_TOKEN=your_token
|
|
61
|
+
|
|
62
|
+
webextrator extract https://www.amazon.com/dp/B0C1234567
|
|
63
|
+
webextrator render https://example.com
|
|
64
|
+
webextrator tasks retrieve --id <task-id>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
| Command | Description |
|
|
70
|
+
|---------|-------------|
|
|
71
|
+
| `webextrator extract <url>` | Extract structured content from a web page |
|
|
72
|
+
| `webextrator render <url>` | Render a web page and return the HTML |
|
|
73
|
+
| `webextrator tasks retrieve` | Retrieve a single task by ID or trace ID |
|
|
74
|
+
| `webextrator tasks batch` | Retrieve multiple tasks at once |
|
|
75
|
+
| `webextrator config` | Show current configuration |
|
|
76
|
+
|
|
77
|
+
## Global Options
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
--token TEXT API token (or set ACEDATACLOUD_API_TOKEN env var)
|
|
81
|
+
--version Show version
|
|
82
|
+
--help Show help message
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
Set environment variables or use a `.env` file:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
ACEDATACLOUD_API_TOKEN=your_token
|
|
91
|
+
ACEDATACLOUD_API_BASE_URL=https://api.acedata.cloud
|
|
92
|
+
WEBEXTRATOR_REQUEST_TIMEOUT=60
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
webextrator_cli/__init__.py,sha256=hnkmABdt_nUDcRh7s_qnlbryMOmL9WOK5vdlfGONikY,54
|
|
2
|
+
webextrator_cli/__main__.py,sha256=UwsREVewLZPy5FMKrTsVjYcLdG--PNDesfI4h3roFZM,125
|
|
3
|
+
webextrator_cli/main.py,sha256=01F3HwlNU1S3PJ5yaYLaZkUq63y65KGh_WqceblhvB8,1587
|
|
4
|
+
webextrator_cli/commands/__init__.py,sha256=yPsetRwqRP3ugV3tdPAGSdUHNN9hsHIN4qAUNqyHDk4,40
|
|
5
|
+
webextrator_cli/commands/info.py,sha256=nf05whSwk0oC9HvbEXqGcjjwtJCY5zPENlnw-F9xiaQ,683
|
|
6
|
+
webextrator_cli/commands/task.py,sha256=UzY_ahz-7j_6fUkzzAVcX5UODwo4dYNeHQp7cSZTz4I,3218
|
|
7
|
+
webextrator_cli/commands/web.py,sha256=EWK_lT4XRsZoUXWwzdLRPo_gghDlMhcJ62QI1iYfTc0,6958
|
|
8
|
+
webextrator_cli/core/__init__.py,sha256=keeJu-Y-FrpTbLSJjVHUGywB_xtvn2ZJledaoNpVWmI,36
|
|
9
|
+
webextrator_cli/core/client.py,sha256=2J5R1IL5jq-DWR-PJQbYxf5szkeo69RZ0SxKodz909I,3637
|
|
10
|
+
webextrator_cli/core/config.py,sha256=fy2RHL4OdkFdy4Xz-Oi9f9RtfwrZDulruTinNKt7UH0,1128
|
|
11
|
+
webextrator_cli/core/exceptions.py,sha256=FtI9U97z24kKRiaxe8E5CMkCb8YPkOKLgCNpm1FSC0o,1006
|
|
12
|
+
webextrator_cli/core/output.py,sha256=NNBIvWjZBZPRoW2t4auuR309hwvBs1pdzwN_NSmO2i0,3839
|
|
13
|
+
webextrator_cli-2026.7.1.0.dist-info/METADATA,sha256=yUnaD2J8a9ShQcTqVh6w-NM3BnIUhCvezfLL_bhIWQk,3134
|
|
14
|
+
webextrator_cli-2026.7.1.0.dist-info/WHEEL,sha256=TJPnKdtrSue7xZ_AVGkp9YXcvDrobsjBds1du3Nx6dc,87
|
|
15
|
+
webextrator_cli-2026.7.1.0.dist-info/entry_points.txt,sha256=dI54Jr_ZMP_WLzmKdpOsK2zV9uEPD9ubC-IQKiOscyk,100
|
|
16
|
+
webextrator_cli-2026.7.1.0.dist-info/licenses/LICENSE,sha256=pi1I0LFwS_WQBwOlBks6BkKATEJxYi54t8Q22FQ7ufo,1069
|
|
17
|
+
webextrator_cli-2026.7.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 AceDataCloud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|