web2cli 0.2.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.
- web2cli/__init__.py +3 -0
- web2cli/__main__.py +5 -0
- web2cli/adapter/__init__.py +0 -0
- web2cli/adapter/lint.py +667 -0
- web2cli/adapter/loader.py +157 -0
- web2cli/adapter/validator.py +127 -0
- web2cli/adapters/discord.com/web2cli.yaml +476 -0
- web2cli/adapters/mail.google.com/parsers/inbox.py +200 -0
- web2cli/adapters/mail.google.com/web2cli.yaml +52 -0
- web2cli/adapters/news.ycombinator.com/web2cli.yaml +356 -0
- web2cli/adapters/reddit.com/web2cli.yaml +233 -0
- web2cli/adapters/slack.com/web2cli.yaml +445 -0
- web2cli/adapters/stackoverflow.com/web2cli.yaml +257 -0
- web2cli/adapters/x.com/providers/x_graphql.py +299 -0
- web2cli/adapters/x.com/web2cli.yaml +449 -0
- web2cli/auth/__init__.py +0 -0
- web2cli/auth/browser_login.py +820 -0
- web2cli/auth/manager.py +166 -0
- web2cli/auth/store.py +68 -0
- web2cli/cli.py +1286 -0
- web2cli/executor/__init__.py +0 -0
- web2cli/executor/http.py +113 -0
- web2cli/output/__init__.py +0 -0
- web2cli/output/formatter.py +116 -0
- web2cli/parser/__init__.py +0 -0
- web2cli/parser/custom.py +21 -0
- web2cli/parser/html_parser.py +111 -0
- web2cli/parser/transforms.py +127 -0
- web2cli/pipe.py +10 -0
- web2cli/providers/__init__.py +6 -0
- web2cli/providers/base.py +22 -0
- web2cli/providers/registry.py +86 -0
- web2cli/runtime/__init__.py +1 -0
- web2cli/runtime/cache.py +42 -0
- web2cli/runtime/engine.py +743 -0
- web2cli/runtime/parser.py +398 -0
- web2cli/runtime/template.py +52 -0
- web2cli/types.py +71 -0
- web2cli-0.2.0.dist-info/METADATA +467 -0
- web2cli-0.2.0.dist-info/RECORD +44 -0
- web2cli-0.2.0.dist-info/WHEEL +5 -0
- web2cli-0.2.0.dist-info/entry_points.txt +2 -0
- web2cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- web2cli-0.2.0.dist-info/top_level.txt +1 -0
web2cli/cli.py
ADDED
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
"""CLI entry point for web2cli."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
from typer.core import TyperGroup
|
|
10
|
+
|
|
11
|
+
from web2cli import __version__
|
|
12
|
+
from web2cli.adapter.lint import lint_adapter
|
|
13
|
+
from web2cli.adapter.loader import AdapterNotFound, load_adapter, list_adapters
|
|
14
|
+
from web2cli.auth.manager import (
|
|
15
|
+
check_session,
|
|
16
|
+
create_session,
|
|
17
|
+
get_session,
|
|
18
|
+
parse_cookie_file,
|
|
19
|
+
parse_cookie_string,
|
|
20
|
+
remove_session,
|
|
21
|
+
)
|
|
22
|
+
from web2cli.auth.browser_login import (
|
|
23
|
+
AutoCdpSession,
|
|
24
|
+
BrowserLoginCancelled,
|
|
25
|
+
BrowserLoginError,
|
|
26
|
+
TokenCaptureRule,
|
|
27
|
+
capture_auth_with_browser,
|
|
28
|
+
find_local_chrome_executable,
|
|
29
|
+
probe_cdp_endpoint,
|
|
30
|
+
start_auto_cdp_chrome,
|
|
31
|
+
stop_auto_cdp_chrome,
|
|
32
|
+
)
|
|
33
|
+
from web2cli.auth.store import SESSIONS_DIR
|
|
34
|
+
from web2cli.executor.http import HttpError
|
|
35
|
+
from web2cli.output.formatter import format_output
|
|
36
|
+
from web2cli.pipe import read_stdin
|
|
37
|
+
from web2cli.types import AdapterSpec, CommandArg, CommandSpec
|
|
38
|
+
from web2cli.runtime.engine import execute_command
|
|
39
|
+
|
|
40
|
+
err = Console(stderr=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CommandArgsError(Exception):
|
|
44
|
+
"""Raised for user-facing command argument validation errors."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Custom TyperGroup: routes unknown subcommands to "run" handler
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DynamicGroup(TyperGroup):
|
|
53
|
+
def parse_args(self, ctx, args):
|
|
54
|
+
if args and not args[0].startswith("-") and args[0] not in self.commands:
|
|
55
|
+
args = ["run"] + args
|
|
56
|
+
return super().parse_args(ctx, args)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
app = typer.Typer(
|
|
60
|
+
name="web2cli",
|
|
61
|
+
help="Every website is a command.\n\nUsage: web2cli <domain> <command> [--args] [--format] [--fields] [--raw] [--trace] [--verbose] [--no-color]",
|
|
62
|
+
no_args_is_help=True,
|
|
63
|
+
add_completion=False,
|
|
64
|
+
cls=DynamicGroup,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Dynamic argument parsing
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_dynamic_args(
|
|
74
|
+
raw_args: list[str], arg_specs: dict[str, CommandArg]
|
|
75
|
+
) -> tuple[dict, dict]:
|
|
76
|
+
"""Parse CLI args against command arg definitions.
|
|
77
|
+
|
|
78
|
+
Returns (command_args, extra_global_flags).
|
|
79
|
+
"""
|
|
80
|
+
command_args: dict = {}
|
|
81
|
+
global_flags: dict = {}
|
|
82
|
+
i = 0
|
|
83
|
+
|
|
84
|
+
while i < len(raw_args):
|
|
85
|
+
token = raw_args[i]
|
|
86
|
+
if not token.startswith("--"):
|
|
87
|
+
i += 1
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
key = token[2:].replace("-", "_")
|
|
91
|
+
|
|
92
|
+
# Find matching arg spec — exact, then unambiguous prefix
|
|
93
|
+
spec = arg_specs.get(key)
|
|
94
|
+
if spec is None:
|
|
95
|
+
matches = [n for n in arg_specs if n.startswith(key)]
|
|
96
|
+
if len(matches) == 1:
|
|
97
|
+
key = matches[0]
|
|
98
|
+
spec = arg_specs[key]
|
|
99
|
+
|
|
100
|
+
# Unknown arg → store as extra global flag
|
|
101
|
+
if spec is None:
|
|
102
|
+
if i + 1 < len(raw_args) and not raw_args[i + 1].startswith("--"):
|
|
103
|
+
global_flags[token[2:].replace("-", "_")] = raw_args[i + 1]
|
|
104
|
+
i += 2
|
|
105
|
+
else:
|
|
106
|
+
global_flags[token[2:].replace("-", "_")] = True
|
|
107
|
+
i += 1
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Flag type — no value
|
|
111
|
+
if spec.type == "flag":
|
|
112
|
+
command_args[key] = True
|
|
113
|
+
i += 1
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# string[] — collect repeated values
|
|
117
|
+
if spec.type == "string[]":
|
|
118
|
+
command_args.setdefault(key, [])
|
|
119
|
+
i += 1
|
|
120
|
+
if i < len(raw_args) and not raw_args[i].startswith("--"):
|
|
121
|
+
command_args[key].append(raw_args[i])
|
|
122
|
+
i += 1
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# All other types: consume next token as value
|
|
126
|
+
i += 1
|
|
127
|
+
if i >= len(raw_args):
|
|
128
|
+
raise CommandArgsError(f"--{key} expects a value")
|
|
129
|
+
raw_value = raw_args[i]
|
|
130
|
+
i += 1
|
|
131
|
+
|
|
132
|
+
if spec.type == "int":
|
|
133
|
+
try:
|
|
134
|
+
command_args[key] = int(raw_value)
|
|
135
|
+
except ValueError:
|
|
136
|
+
raise CommandArgsError(
|
|
137
|
+
f"--{key} expects an integer, got '{raw_value}'"
|
|
138
|
+
)
|
|
139
|
+
elif spec.type == "float":
|
|
140
|
+
try:
|
|
141
|
+
command_args[key] = float(raw_value)
|
|
142
|
+
except ValueError:
|
|
143
|
+
raise CommandArgsError(
|
|
144
|
+
f"--{key} expects a number, got '{raw_value}'"
|
|
145
|
+
)
|
|
146
|
+
elif spec.type == "bool":
|
|
147
|
+
command_args[key] = raw_value.lower() in ("true", "1", "yes")
|
|
148
|
+
else:
|
|
149
|
+
command_args[key] = raw_value
|
|
150
|
+
|
|
151
|
+
# Apply defaults
|
|
152
|
+
for name, spec in arg_specs.items():
|
|
153
|
+
if name not in command_args and spec.default is not None:
|
|
154
|
+
command_args[name] = spec.default
|
|
155
|
+
|
|
156
|
+
return command_args, global_flags
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def validate_command_args(command_args: dict, arg_specs: dict[str, CommandArg]) -> None:
|
|
160
|
+
"""Validate parsed args. Raises CommandArgsError on invalid input."""
|
|
161
|
+
# Required
|
|
162
|
+
missing = [
|
|
163
|
+
name for name, spec in arg_specs.items()
|
|
164
|
+
if spec.required and name not in command_args
|
|
165
|
+
]
|
|
166
|
+
if missing:
|
|
167
|
+
raise CommandArgsError(
|
|
168
|
+
f"Missing required arguments: {', '.join('--' + m for m in missing)}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Enum
|
|
172
|
+
for name, spec in arg_specs.items():
|
|
173
|
+
if spec.enum and name in command_args and command_args[name] not in spec.enum:
|
|
174
|
+
raise CommandArgsError(
|
|
175
|
+
f"--{name} must be one of: {', '.join(spec.enum)}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Min/max clamping
|
|
179
|
+
for name, spec in arg_specs.items():
|
|
180
|
+
if name in command_args and isinstance(command_args[name], (int, float)):
|
|
181
|
+
if spec.min is not None and command_args[name] < spec.min:
|
|
182
|
+
command_args[name] = spec.min
|
|
183
|
+
if spec.max is not None and command_args[name] > spec.max:
|
|
184
|
+
command_args[name] = spec.max
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Help helpers
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
GLOBAL_FLAGS_HELP = """\
|
|
193
|
+
[bold]Global flags:[/bold]
|
|
194
|
+
--format, -f Output format: table, json, csv, plain, md
|
|
195
|
+
--fields Comma-separated list of fields to display
|
|
196
|
+
--no-truncate Disable all parser-level truncate rules
|
|
197
|
+
--sort Override output sort field (if command doesn't use --sort)
|
|
198
|
+
--sort-by Override output sort field (always safe)
|
|
199
|
+
--raw Show raw HTTP response body
|
|
200
|
+
--trace Show pipeline step trace (debug)
|
|
201
|
+
--verbose Show request URL, params, and timing
|
|
202
|
+
--no-color Disable colors and use ASCII table borders
|
|
203
|
+
--no-header Omit header row (csv only)"""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def print_adapter_info(adapter: AdapterSpec) -> None:
|
|
207
|
+
aliases = ", ".join(adapter.meta.aliases)
|
|
208
|
+
err.print(f"\n[bold]{adapter.meta.name}[/bold] — {adapter.meta.description}")
|
|
209
|
+
if aliases:
|
|
210
|
+
err.print(f" aliases: {aliases}")
|
|
211
|
+
err.print(f"\n[bold]Commands:[/bold]")
|
|
212
|
+
for name, cmd in adapter.commands.items():
|
|
213
|
+
err.print(f" {name:15} {cmd.description}")
|
|
214
|
+
err.print()
|
|
215
|
+
err.print(GLOBAL_FLAGS_HELP)
|
|
216
|
+
err.print()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _unique_extend(dest: list[str], values: list[str]) -> None:
|
|
220
|
+
for value in values:
|
|
221
|
+
if value and value not in dest:
|
|
222
|
+
dest.append(value)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _cookie_keys_from_auth_spec(auth_spec: dict | None) -> list[str]:
|
|
226
|
+
if not isinstance(auth_spec, dict):
|
|
227
|
+
return []
|
|
228
|
+
methods = auth_spec.get("methods", [])
|
|
229
|
+
if not isinstance(methods, list):
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
out: list[str] = []
|
|
233
|
+
for method in methods:
|
|
234
|
+
if not isinstance(method, dict):
|
|
235
|
+
continue
|
|
236
|
+
method_type = str(method.get("type", "cookies")).lower()
|
|
237
|
+
if method_type != "cookies":
|
|
238
|
+
continue
|
|
239
|
+
keys = method.get("keys", [])
|
|
240
|
+
if not isinstance(keys, list):
|
|
241
|
+
continue
|
|
242
|
+
for key in keys:
|
|
243
|
+
if isinstance(key, str) and key and key not in out:
|
|
244
|
+
out.append(key)
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _token_capture_rules_from_auth_spec(auth_spec: dict | None) -> list[TokenCaptureRule]:
|
|
249
|
+
if not isinstance(auth_spec, dict):
|
|
250
|
+
return []
|
|
251
|
+
methods = auth_spec.get("methods", [])
|
|
252
|
+
if not isinstance(methods, list):
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
out: list[TokenCaptureRule] = []
|
|
256
|
+
for method in methods:
|
|
257
|
+
if not isinstance(method, dict):
|
|
258
|
+
continue
|
|
259
|
+
method_type = str(method.get("type", "")).lower()
|
|
260
|
+
if method_type != "token":
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
capture = method.get("capture")
|
|
264
|
+
if not isinstance(capture, dict):
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
source = str(capture.get("from", "")).strip().lower()
|
|
268
|
+
key = str(capture.get("key", "")).strip()
|
|
269
|
+
if not source or not key:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
match = capture.get("match")
|
|
273
|
+
match_dict = match if isinstance(match, dict) else {}
|
|
274
|
+
host = match_dict.get("host")
|
|
275
|
+
path_regex = match_dict.get("path_regex")
|
|
276
|
+
method_name = match_dict.get("method")
|
|
277
|
+
strip_prefix = capture.get("strip_prefix")
|
|
278
|
+
|
|
279
|
+
out.append(
|
|
280
|
+
TokenCaptureRule(
|
|
281
|
+
source=source,
|
|
282
|
+
key=key,
|
|
283
|
+
host=str(host).strip() if isinstance(host, str) and host.strip() else None,
|
|
284
|
+
path_regex=(
|
|
285
|
+
str(path_regex).strip()
|
|
286
|
+
if isinstance(path_regex, str) and path_regex.strip()
|
|
287
|
+
else None
|
|
288
|
+
),
|
|
289
|
+
method=(
|
|
290
|
+
str(method_name).strip().upper()
|
|
291
|
+
if isinstance(method_name, str) and method_name.strip()
|
|
292
|
+
else None
|
|
293
|
+
),
|
|
294
|
+
strip_prefix=(
|
|
295
|
+
str(strip_prefix)
|
|
296
|
+
if isinstance(strip_prefix, str) and strip_prefix
|
|
297
|
+
else None
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _token_capture_rule_text(rule: TokenCaptureRule) -> str:
|
|
305
|
+
parts = [f"{rule.source}:{rule.key}"]
|
|
306
|
+
if rule.method:
|
|
307
|
+
parts.append(rule.method.upper())
|
|
308
|
+
if rule.host:
|
|
309
|
+
parts.append(rule.host)
|
|
310
|
+
if rule.path_regex:
|
|
311
|
+
parts.append(f"path~{rule.path_regex}")
|
|
312
|
+
if rule.strip_prefix:
|
|
313
|
+
parts.append(f"strip_prefix={rule.strip_prefix!r}")
|
|
314
|
+
return " ".join(parts)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _print_login_auth_guide(
|
|
318
|
+
adapter: AdapterSpec,
|
|
319
|
+
*,
|
|
320
|
+
resolved_domain: str,
|
|
321
|
+
login_target: str,
|
|
322
|
+
) -> None:
|
|
323
|
+
auth = adapter.auth if isinstance(adapter.auth, dict) else {}
|
|
324
|
+
methods = auth.get("methods", [])
|
|
325
|
+
if not isinstance(methods, list):
|
|
326
|
+
methods = []
|
|
327
|
+
|
|
328
|
+
cookie_keys: list[str] = []
|
|
329
|
+
cookie_env_vars: list[str] = []
|
|
330
|
+
token_env_vars: list[str] = []
|
|
331
|
+
for method in methods:
|
|
332
|
+
if not isinstance(method, dict):
|
|
333
|
+
continue
|
|
334
|
+
mtype = str(method.get("type", "cookies")).lower()
|
|
335
|
+
env_var = method.get("env_var")
|
|
336
|
+
if mtype == "cookies":
|
|
337
|
+
keys = method.get("keys", [])
|
|
338
|
+
if isinstance(keys, list):
|
|
339
|
+
for key in keys:
|
|
340
|
+
if isinstance(key, str) and key and key not in cookie_keys:
|
|
341
|
+
cookie_keys.append(key)
|
|
342
|
+
if isinstance(env_var, str) and env_var and env_var not in cookie_env_vars:
|
|
343
|
+
cookie_env_vars.append(env_var)
|
|
344
|
+
elif mtype == "token":
|
|
345
|
+
if isinstance(env_var, str) and env_var and env_var not in token_env_vars:
|
|
346
|
+
token_env_vars.append(env_var)
|
|
347
|
+
|
|
348
|
+
token_capture_rules = _token_capture_rules_from_auth_spec(adapter.auth)
|
|
349
|
+
browser_supported = bool(cookie_keys or token_capture_rules)
|
|
350
|
+
|
|
351
|
+
err.print()
|
|
352
|
+
err.print(f"[bold]Auth guide for {resolved_domain}[/bold]")
|
|
353
|
+
|
|
354
|
+
if browser_supported:
|
|
355
|
+
err.print(
|
|
356
|
+
f" [green]Auto capture (recommended):[/green] "
|
|
357
|
+
f"`web2cli login {login_target} --browser`"
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
err.print(" [yellow]Auto capture:[/yellow] not configured in this adapter")
|
|
361
|
+
|
|
362
|
+
if cookie_keys:
|
|
363
|
+
cookie_example = "; ".join(f"{k}=<value>" for k in cookie_keys)
|
|
364
|
+
cookie_json_example = ", ".join(f'"{k}": "<value>"' for k in cookie_keys)
|
|
365
|
+
err.print(f" Cookies required: {', '.join(cookie_keys)}")
|
|
366
|
+
err.print(
|
|
367
|
+
f" Manual: `web2cli login {login_target} --cookies \"{cookie_example}\"`"
|
|
368
|
+
)
|
|
369
|
+
err.print(
|
|
370
|
+
f" File: `web2cli login {login_target} --cookie-file /path/cookies.json` "
|
|
371
|
+
f"(JSON: {{{cookie_json_example}}})"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if token_env_vars or token_capture_rules:
|
|
375
|
+
err.print(f" Token: `web2cli login {login_target} --token \"<token>\"`")
|
|
376
|
+
|
|
377
|
+
if cookie_env_vars:
|
|
378
|
+
cookie_env_example = "; ".join(f"{k}=<value>" for k in cookie_keys) or "k=v"
|
|
379
|
+
for env_var in cookie_env_vars:
|
|
380
|
+
err.print(f" Env: `export {env_var}=\"{cookie_env_example}\"`")
|
|
381
|
+
|
|
382
|
+
if token_env_vars:
|
|
383
|
+
for env_var in token_env_vars:
|
|
384
|
+
err.print(f" Env: `export {env_var}=\"<token>\"`")
|
|
385
|
+
|
|
386
|
+
if token_capture_rules:
|
|
387
|
+
err.print(" Token capture rules from adapter:")
|
|
388
|
+
for idx, rule in enumerate(token_capture_rules, start=1):
|
|
389
|
+
err.print(f" {idx}. {_token_capture_rule_text(rule)}")
|
|
390
|
+
err.print(
|
|
391
|
+
" Manual token extraction: open DevTools -> Network, find a request matching "
|
|
392
|
+
"the rule, copy the token value."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
err.print(f" Check: `web2cli login {login_target} --status`")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _field_names_from_parse_spec(parse_spec: dict[str, Any]) -> list[str]:
|
|
399
|
+
fields = parse_spec.get("fields")
|
|
400
|
+
if not isinstance(fields, list):
|
|
401
|
+
return []
|
|
402
|
+
out: list[str] = []
|
|
403
|
+
for field in fields:
|
|
404
|
+
if isinstance(field, dict) and isinstance(field.get("name"), str):
|
|
405
|
+
_unique_extend(out, [field["name"]])
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _resource_output_fields(
|
|
410
|
+
adapter: AdapterSpec,
|
|
411
|
+
resource_name: str,
|
|
412
|
+
) -> tuple[list[str], bool]:
|
|
413
|
+
resource_spec = adapter.resources.get(resource_name)
|
|
414
|
+
if not isinstance(resource_spec, dict):
|
|
415
|
+
return [], False
|
|
416
|
+
|
|
417
|
+
parse_spec = resource_spec.get("response") or resource_spec.get("parse")
|
|
418
|
+
if not isinstance(parse_spec, dict):
|
|
419
|
+
return [], False
|
|
420
|
+
|
|
421
|
+
names = _field_names_from_parse_spec(parse_spec)
|
|
422
|
+
if names:
|
|
423
|
+
return names, True
|
|
424
|
+
return [], False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _collect_pipeline_steps(cmd: CommandSpec) -> list[tuple[str, str, dict[str, Any]]]:
|
|
428
|
+
steps: list[tuple[str, str, dict[str, Any]]] = []
|
|
429
|
+
for idx, raw_step in enumerate(cmd.pipeline or []):
|
|
430
|
+
if not isinstance(raw_step, dict):
|
|
431
|
+
continue
|
|
432
|
+
step_type = None
|
|
433
|
+
for key in ("resolve", "request", "fanout", "parse", "transform"):
|
|
434
|
+
if key in raw_step:
|
|
435
|
+
step_type = key
|
|
436
|
+
break
|
|
437
|
+
if step_type is None:
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
step_spec = raw_step.get(step_type) or {}
|
|
441
|
+
if not isinstance(step_spec, dict):
|
|
442
|
+
step_spec = {}
|
|
443
|
+
step_name = str(step_spec.get("name") or raw_step.get("name") or f"{step_type}_{idx}")
|
|
444
|
+
steps.append((step_name, step_type, step_spec))
|
|
445
|
+
return steps
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _infer_command_fields(
|
|
449
|
+
adapter: AdapterSpec,
|
|
450
|
+
cmd: CommandSpec,
|
|
451
|
+
) -> tuple[list[str], list[str], bool]:
|
|
452
|
+
raw_default_fields = cmd.output.get("default_fields")
|
|
453
|
+
default_fields = (
|
|
454
|
+
[str(x) for x in raw_default_fields if str(x)]
|
|
455
|
+
if isinstance(raw_default_fields, list)
|
|
456
|
+
else []
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
steps = _collect_pipeline_steps(cmd)
|
|
460
|
+
if not steps:
|
|
461
|
+
return list(default_fields), default_fields, False
|
|
462
|
+
|
|
463
|
+
step_index = {name: i for i, (name, _, _) in enumerate(steps)}
|
|
464
|
+
cache: dict[str, tuple[list[str], bool]] = {}
|
|
465
|
+
|
|
466
|
+
def _prev_step_name(index: int) -> str | None:
|
|
467
|
+
return steps[index - 1][0] if index > 0 else None
|
|
468
|
+
|
|
469
|
+
def _infer(step_name: str, visiting: set[str]) -> tuple[list[str], bool]:
|
|
470
|
+
if step_name in cache:
|
|
471
|
+
return cache[step_name]
|
|
472
|
+
if step_name in visiting:
|
|
473
|
+
return [], False
|
|
474
|
+
index = step_index.get(step_name)
|
|
475
|
+
if index is None:
|
|
476
|
+
return [], False
|
|
477
|
+
|
|
478
|
+
visiting.add(step_name)
|
|
479
|
+
_, step_type, step_spec = steps[index]
|
|
480
|
+
fields: list[str] = []
|
|
481
|
+
complete = True
|
|
482
|
+
|
|
483
|
+
if step_type == "parse":
|
|
484
|
+
if step_spec.get("parser") == "custom":
|
|
485
|
+
complete = False
|
|
486
|
+
else:
|
|
487
|
+
parse_fields = _field_names_from_parse_spec(step_spec)
|
|
488
|
+
if parse_fields:
|
|
489
|
+
_unique_extend(fields, parse_fields)
|
|
490
|
+
else:
|
|
491
|
+
complete = False
|
|
492
|
+
from_step = step_spec.get("from") or _prev_step_name(index)
|
|
493
|
+
if isinstance(from_step, str):
|
|
494
|
+
source_fields, source_complete = _infer(from_step, visiting)
|
|
495
|
+
_unique_extend(fields, source_fields)
|
|
496
|
+
complete = complete and source_complete
|
|
497
|
+
|
|
498
|
+
elif step_type == "resolve":
|
|
499
|
+
resource_name = step_spec.get("resource")
|
|
500
|
+
if isinstance(resource_name, str):
|
|
501
|
+
resource_fields, resource_complete = _resource_output_fields(adapter, resource_name)
|
|
502
|
+
_unique_extend(fields, resource_fields)
|
|
503
|
+
complete = resource_complete
|
|
504
|
+
else:
|
|
505
|
+
complete = False
|
|
506
|
+
|
|
507
|
+
elif step_type == "transform":
|
|
508
|
+
from_step = step_spec.get("from") or _prev_step_name(index)
|
|
509
|
+
if isinstance(from_step, str):
|
|
510
|
+
source_fields, source_complete = _infer(from_step, visiting)
|
|
511
|
+
_unique_extend(fields, source_fields)
|
|
512
|
+
complete = source_complete
|
|
513
|
+
else:
|
|
514
|
+
complete = False
|
|
515
|
+
|
|
516
|
+
ops = step_spec.get("ops", [])
|
|
517
|
+
if isinstance(ops, list):
|
|
518
|
+
for op in ops:
|
|
519
|
+
if not (isinstance(op, dict) and "concat" in op):
|
|
520
|
+
continue
|
|
521
|
+
cfg = op.get("concat") or {}
|
|
522
|
+
extra_steps = cfg.get("steps", [])
|
|
523
|
+
if isinstance(extra_steps, str):
|
|
524
|
+
extra_steps = [extra_steps]
|
|
525
|
+
if not isinstance(extra_steps, list):
|
|
526
|
+
complete = False
|
|
527
|
+
continue
|
|
528
|
+
for extra_step in extra_steps:
|
|
529
|
+
if not isinstance(extra_step, str):
|
|
530
|
+
complete = False
|
|
531
|
+
continue
|
|
532
|
+
extra_fields, extra_complete = _infer(extra_step, visiting)
|
|
533
|
+
_unique_extend(fields, extra_fields)
|
|
534
|
+
complete = complete and extra_complete
|
|
535
|
+
|
|
536
|
+
else:
|
|
537
|
+
# request/fanout output shape is dynamic unless parsed later.
|
|
538
|
+
complete = False
|
|
539
|
+
|
|
540
|
+
visiting.remove(step_name)
|
|
541
|
+
cache[step_name] = (fields, complete)
|
|
542
|
+
return cache[step_name]
|
|
543
|
+
|
|
544
|
+
output_from = cmd.output.get("from_step")
|
|
545
|
+
if not isinstance(output_from, str) or output_from not in step_index:
|
|
546
|
+
output_from = steps[-1][0]
|
|
547
|
+
|
|
548
|
+
inferred_fields, fields_complete = _infer(output_from, set())
|
|
549
|
+
available_fields = list(inferred_fields)
|
|
550
|
+
_unique_extend(available_fields, default_fields)
|
|
551
|
+
return available_fields, default_fields, fields_complete
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def print_command_help(adapter: AdapterSpec, cmd: CommandSpec) -> None:
|
|
555
|
+
err.print(
|
|
556
|
+
f"\n[bold]web2cli {adapter.meta.name} {cmd.name}[/bold]"
|
|
557
|
+
f" — {cmd.description}\n"
|
|
558
|
+
)
|
|
559
|
+
if not cmd.args:
|
|
560
|
+
err.print(" (no arguments)")
|
|
561
|
+
else:
|
|
562
|
+
err.print("[bold]Arguments:[/bold]")
|
|
563
|
+
for name, arg in cmd.args.items():
|
|
564
|
+
req = "[red]required[/red]" if arg.required else f"default: {arg.default}"
|
|
565
|
+
desc = arg.description or ""
|
|
566
|
+
enum_str = f" [{', '.join(arg.enum)}]" if arg.enum else ""
|
|
567
|
+
pipe_str = " [dim]pipeable[/dim]" if "stdin" in arg.source else ""
|
|
568
|
+
err.print(f" --{name:15} {arg.type:10} {desc}{enum_str} ({req}){pipe_str}")
|
|
569
|
+
|
|
570
|
+
available_fields, default_fields, fields_complete = _infer_command_fields(adapter, cmd)
|
|
571
|
+
if available_fields:
|
|
572
|
+
default_set = set(default_fields)
|
|
573
|
+
err.print("\n[bold]Fields:[/bold]")
|
|
574
|
+
for field_name in available_fields:
|
|
575
|
+
suffix = " [green](default)[/green]" if field_name in default_set else ""
|
|
576
|
+
err.print(f" {field_name}{suffix}")
|
|
577
|
+
if not fields_complete:
|
|
578
|
+
err.print(" [dim](inferred list may be incomplete for dynamic outputs)[/dim]")
|
|
579
|
+
|
|
580
|
+
err.print()
|
|
581
|
+
err.print(GLOBAL_FLAGS_HELP)
|
|
582
|
+
err.print()
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ---------------------------------------------------------------------------
|
|
586
|
+
# Main command: web2cli <domain> <command> [--args]
|
|
587
|
+
# ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@app.command(
|
|
591
|
+
"run",
|
|
592
|
+
hidden=True,
|
|
593
|
+
context_settings={
|
|
594
|
+
"allow_extra_args": True,
|
|
595
|
+
"allow_interspersed_args": True,
|
|
596
|
+
"ignore_unknown_options": True,
|
|
597
|
+
"help_option_names": [],
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
def run_command(
|
|
601
|
+
ctx: typer.Context,
|
|
602
|
+
domain: str = typer.Argument(
|
|
603
|
+
...,
|
|
604
|
+
help="Domain or adapter alias",
|
|
605
|
+
),
|
|
606
|
+
command: str = typer.Argument(
|
|
607
|
+
None,
|
|
608
|
+
help="Command to execute",
|
|
609
|
+
),
|
|
610
|
+
output_format: str = typer.Option(
|
|
611
|
+
None, "--format", "-f", help="Output format (table|json|csv|plain)"
|
|
612
|
+
),
|
|
613
|
+
fields: str = typer.Option(None, "--fields", help="Comma-separated fields"),
|
|
614
|
+
no_truncate: bool = typer.Option(
|
|
615
|
+
False,
|
|
616
|
+
"--no-truncate",
|
|
617
|
+
help="Disable parser-level truncation rules",
|
|
618
|
+
),
|
|
619
|
+
raw: bool = typer.Option(False, "--raw", help="Show raw HTTP response"),
|
|
620
|
+
trace: bool = typer.Option(False, "--trace", help="Show pipeline step trace"),
|
|
621
|
+
verbose: bool = typer.Option(False, "--verbose", help="Show request details"),
|
|
622
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
|
|
623
|
+
no_header: bool = typer.Option(False, "--no-header", help="Omit header row (csv)"),
|
|
624
|
+
) -> None:
|
|
625
|
+
"""Execute an adapter command."""
|
|
626
|
+
# Load adapter
|
|
627
|
+
try:
|
|
628
|
+
adapter = load_adapter(domain)
|
|
629
|
+
except AdapterNotFound as e:
|
|
630
|
+
err.print(f"[red]{e}[/red]")
|
|
631
|
+
raise typer.Exit(1)
|
|
632
|
+
|
|
633
|
+
# Help handling
|
|
634
|
+
help_requested = "--help" in ctx.args or command == "--help"
|
|
635
|
+
if command == "--help":
|
|
636
|
+
command = None
|
|
637
|
+
|
|
638
|
+
# No command → show adapter info
|
|
639
|
+
if command is None or (help_requested and command not in adapter.commands):
|
|
640
|
+
print_adapter_info(adapter)
|
|
641
|
+
raise typer.Exit(0)
|
|
642
|
+
|
|
643
|
+
# Command help
|
|
644
|
+
if help_requested and command in adapter.commands:
|
|
645
|
+
print_command_help(adapter, adapter.commands[command])
|
|
646
|
+
raise typer.Exit(0)
|
|
647
|
+
|
|
648
|
+
# Resolve command
|
|
649
|
+
if command not in adapter.commands:
|
|
650
|
+
err.print(f"[red]Unknown command '{command}' for {adapter.meta.domain}[/red]")
|
|
651
|
+
err.print(f"Available: {', '.join(adapter.commands.keys())}")
|
|
652
|
+
raise typer.Exit(1)
|
|
653
|
+
|
|
654
|
+
cmd_spec = adapter.commands[command]
|
|
655
|
+
|
|
656
|
+
# Parse + validate command args
|
|
657
|
+
try:
|
|
658
|
+
command_args, extra_globals = parse_dynamic_args(ctx.args, cmd_spec.args)
|
|
659
|
+
except CommandArgsError as e:
|
|
660
|
+
err.print(f"[red]{e}[/red]")
|
|
661
|
+
err.print()
|
|
662
|
+
print_command_help(adapter, cmd_spec)
|
|
663
|
+
raise typer.Exit(1)
|
|
664
|
+
|
|
665
|
+
# Merge extra globals
|
|
666
|
+
for k, v in extra_globals.items():
|
|
667
|
+
if k == "limit":
|
|
668
|
+
try:
|
|
669
|
+
extra_globals[k] = int(v)
|
|
670
|
+
except (ValueError, TypeError):
|
|
671
|
+
pass
|
|
672
|
+
|
|
673
|
+
# --- Stdin injection ---
|
|
674
|
+
stdin_value = read_stdin()
|
|
675
|
+
if stdin_value:
|
|
676
|
+
for arg_name, arg_spec in cmd_spec.args.items():
|
|
677
|
+
if "stdin" in arg_spec.source and arg_name not in command_args:
|
|
678
|
+
command_args[arg_name] = stdin_value
|
|
679
|
+
break
|
|
680
|
+
|
|
681
|
+
# Re-validate after stdin injection (required/enum/min/max rules)
|
|
682
|
+
try:
|
|
683
|
+
validate_command_args(command_args, cmd_spec.args)
|
|
684
|
+
except CommandArgsError as e:
|
|
685
|
+
err.print(f"[red]{e}[/red]")
|
|
686
|
+
err.print()
|
|
687
|
+
print_command_help(adapter, cmd_spec)
|
|
688
|
+
raise typer.Exit(1)
|
|
689
|
+
|
|
690
|
+
# --- Load session (if adapter supports auth) ---
|
|
691
|
+
session = get_session(adapter.meta.domain, adapter.auth)
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
run_result = execute_command(
|
|
695
|
+
adapter=adapter,
|
|
696
|
+
cmd=cmd_spec,
|
|
697
|
+
args=command_args,
|
|
698
|
+
session=session,
|
|
699
|
+
verbose=verbose,
|
|
700
|
+
trace=trace,
|
|
701
|
+
no_truncate=no_truncate,
|
|
702
|
+
)
|
|
703
|
+
except HttpError as e:
|
|
704
|
+
err.print(f"[red]{e}[/red]")
|
|
705
|
+
raise typer.Exit(1)
|
|
706
|
+
except Exception as e:
|
|
707
|
+
err.print(f"[red]{e}[/red]")
|
|
708
|
+
raise typer.Exit(1)
|
|
709
|
+
|
|
710
|
+
if trace:
|
|
711
|
+
for line in run_result.trace_lines:
|
|
712
|
+
err.print(f"[dim]{line}[/dim]")
|
|
713
|
+
|
|
714
|
+
if raw:
|
|
715
|
+
print(run_result.last_response_body or "")
|
|
716
|
+
raise typer.Exit(0)
|
|
717
|
+
|
|
718
|
+
records = run_result.records
|
|
719
|
+
|
|
720
|
+
if not records:
|
|
721
|
+
err.print("[yellow]No results.[/yellow]")
|
|
722
|
+
raise typer.Exit(0)
|
|
723
|
+
|
|
724
|
+
# --- Sort ---
|
|
725
|
+
output_spec = cmd_spec.output
|
|
726
|
+
global_sort = extra_globals.get("sort_by")
|
|
727
|
+
if global_sort is None:
|
|
728
|
+
global_sort = extra_globals.get("sort")
|
|
729
|
+
|
|
730
|
+
if isinstance(global_sort, bool):
|
|
731
|
+
err.print("[red]--sort/--sort-by expects a field name[/red]")
|
|
732
|
+
raise typer.Exit(1)
|
|
733
|
+
|
|
734
|
+
sort_by = global_sort or output_spec.get("sort_by")
|
|
735
|
+
sort_order = output_spec.get("sort_order", "desc")
|
|
736
|
+
should_sort = bool(sort_by)
|
|
737
|
+
|
|
738
|
+
if should_sort and records:
|
|
739
|
+
records.sort(
|
|
740
|
+
key=lambda r: r.get(sort_by, 0) or 0,
|
|
741
|
+
reverse=(sort_order == "desc"),
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# --- Limit ---
|
|
745
|
+
limit = extra_globals.get("limit")
|
|
746
|
+
if limit is None:
|
|
747
|
+
# Use command arg limit for post-processing cap too
|
|
748
|
+
limit = command_args.get("limit")
|
|
749
|
+
if limit:
|
|
750
|
+
try:
|
|
751
|
+
records = records[:int(limit)]
|
|
752
|
+
except (ValueError, TypeError):
|
|
753
|
+
pass
|
|
754
|
+
|
|
755
|
+
# --- Format and output ---
|
|
756
|
+
fmt = output_format or output_spec.get("default_format", "table")
|
|
757
|
+
show_fields = (
|
|
758
|
+
fields.split(",") if fields
|
|
759
|
+
else output_spec.get("default_fields")
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
result = format_output(records, fmt, show_fields, no_color, no_header=no_header)
|
|
763
|
+
if result:
|
|
764
|
+
print(result)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# ---------------------------------------------------------------------------
|
|
768
|
+
# Subcommand: web2cli adapters list|info
|
|
769
|
+
# ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
adapters_app = typer.Typer(
|
|
773
|
+
name="adapters", help="Manage adapters",
|
|
774
|
+
invoke_without_command=True,
|
|
775
|
+
)
|
|
776
|
+
app.add_typer(adapters_app)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@adapters_app.callback()
|
|
780
|
+
def adapters_callback(ctx: typer.Context) -> None:
|
|
781
|
+
if ctx.invoked_subcommand is None:
|
|
782
|
+
typer.echo(ctx.get_help())
|
|
783
|
+
raise typer.Exit(0)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@adapters_app.command("list")
|
|
787
|
+
def adapters_list() -> None:
|
|
788
|
+
"""List all available adapters."""
|
|
789
|
+
for adapter in list_adapters():
|
|
790
|
+
aliases = ", ".join(adapter.meta.aliases)
|
|
791
|
+
alias_str = f" ({aliases})" if aliases else ""
|
|
792
|
+
cmds = ", ".join(adapter.commands.keys())
|
|
793
|
+
err.print(
|
|
794
|
+
f" [bold]{adapter.meta.domain}[/bold]{alias_str}"
|
|
795
|
+
f" — {len(adapter.commands)} commands: {cmds}"
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
@adapters_app.command("info")
|
|
800
|
+
def adapters_info(
|
|
801
|
+
domain: str = typer.Argument(
|
|
802
|
+
...,
|
|
803
|
+
help="Domain or alias",
|
|
804
|
+
),
|
|
805
|
+
) -> None:
|
|
806
|
+
"""Show details for an adapter."""
|
|
807
|
+
try:
|
|
808
|
+
adapter = load_adapter(domain)
|
|
809
|
+
except AdapterNotFound as e:
|
|
810
|
+
err.print(f"[red]{e}[/red]")
|
|
811
|
+
raise typer.Exit(1)
|
|
812
|
+
print_adapter_info(adapter)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@adapters_app.command("validate")
|
|
816
|
+
def adapters_validate() -> None:
|
|
817
|
+
"""Validate all available adapters."""
|
|
818
|
+
had_errors = False
|
|
819
|
+
adapters = list_adapters()
|
|
820
|
+
for adapter in adapters:
|
|
821
|
+
domain = adapter.meta.domain
|
|
822
|
+
try:
|
|
823
|
+
load_adapter(domain)
|
|
824
|
+
err.print(f"[green]ok[/green] {domain}")
|
|
825
|
+
except Exception as e:
|
|
826
|
+
had_errors = True
|
|
827
|
+
err.print(f"[red]error[/red] {domain}: {e}")
|
|
828
|
+
|
|
829
|
+
if had_errors:
|
|
830
|
+
raise typer.Exit(1)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
@adapters_app.command("lint")
|
|
834
|
+
def adapters_lint(
|
|
835
|
+
domain: str | None = typer.Argument(
|
|
836
|
+
None,
|
|
837
|
+
help="Optional domain or alias",
|
|
838
|
+
),
|
|
839
|
+
) -> None:
|
|
840
|
+
"""Run semantic lint checks for adapter specs."""
|
|
841
|
+
had_errors = False
|
|
842
|
+
adapters: list[AdapterSpec] = []
|
|
843
|
+
|
|
844
|
+
if domain:
|
|
845
|
+
try:
|
|
846
|
+
adapters = [load_adapter(domain)]
|
|
847
|
+
except Exception as e:
|
|
848
|
+
err.print(f"[red]error[/red] {domain}: {e}")
|
|
849
|
+
raise typer.Exit(1)
|
|
850
|
+
else:
|
|
851
|
+
for listed in list_adapters():
|
|
852
|
+
try:
|
|
853
|
+
adapters.append(load_adapter(listed.meta.domain))
|
|
854
|
+
except Exception as e:
|
|
855
|
+
had_errors = True
|
|
856
|
+
err.print(f"[red]error[/red] {listed.meta.domain}: {e}")
|
|
857
|
+
|
|
858
|
+
for adapter in adapters:
|
|
859
|
+
issues = lint_adapter(adapter)
|
|
860
|
+
errors = [i for i in issues if i.level == "error"]
|
|
861
|
+
warnings = [i for i in issues if i.level == "warning"]
|
|
862
|
+
|
|
863
|
+
if errors:
|
|
864
|
+
had_errors = True
|
|
865
|
+
err.print(
|
|
866
|
+
f"[red]error[/red] {adapter.meta.domain}: "
|
|
867
|
+
f"{len(errors)} error(s), {len(warnings)} warning(s)"
|
|
868
|
+
)
|
|
869
|
+
elif warnings:
|
|
870
|
+
err.print(
|
|
871
|
+
f"[yellow]warn[/yellow] {adapter.meta.domain}: "
|
|
872
|
+
f"{len(warnings)} warning(s)"
|
|
873
|
+
)
|
|
874
|
+
else:
|
|
875
|
+
err.print(f"[green]ok[/green] {adapter.meta.domain}")
|
|
876
|
+
|
|
877
|
+
for issue in issues:
|
|
878
|
+
level = "E" if issue.level == "error" else "W"
|
|
879
|
+
err.print(f" {level} {issue.path}: {issue.message}")
|
|
880
|
+
|
|
881
|
+
if had_errors:
|
|
882
|
+
raise typer.Exit(1)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ---------------------------------------------------------------------------
|
|
886
|
+
# Subcommand: web2cli doctor
|
|
887
|
+
# ---------------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
doctor_app = typer.Typer(
|
|
891
|
+
name="doctor",
|
|
892
|
+
help="Run environment diagnostics",
|
|
893
|
+
invoke_without_command=True,
|
|
894
|
+
)
|
|
895
|
+
app.add_typer(doctor_app)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@doctor_app.callback()
|
|
899
|
+
def doctor_callback(ctx: typer.Context) -> None:
|
|
900
|
+
if ctx.invoked_subcommand is None:
|
|
901
|
+
typer.echo(ctx.get_help())
|
|
902
|
+
raise typer.Exit(0)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _print_doctor_status(status: str, name: str, detail: str) -> None:
|
|
906
|
+
if status == "ok":
|
|
907
|
+
err.print(f" [green]ok[/green] {name}: {detail}")
|
|
908
|
+
elif status == "warn":
|
|
909
|
+
err.print(f" [yellow]warn[/yellow] {name}: {detail}")
|
|
910
|
+
else:
|
|
911
|
+
err.print(f" [red]fail[/red] {name}: {detail}")
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _doctor_error_summary(exc: Exception, max_len: int = 220) -> str:
|
|
915
|
+
text = str(exc).strip()
|
|
916
|
+
if not text:
|
|
917
|
+
return exc.__class__.__name__
|
|
918
|
+
line = text.splitlines()[0].strip()
|
|
919
|
+
if len(line) > max_len:
|
|
920
|
+
return line[: max_len - 3] + "..."
|
|
921
|
+
return line
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@doctor_app.command("browser")
|
|
925
|
+
def doctor_browser(
|
|
926
|
+
deep: bool = typer.Option(
|
|
927
|
+
False,
|
|
928
|
+
"--deep",
|
|
929
|
+
help="Run launch smoke tests (starts/stops browser processes)",
|
|
930
|
+
),
|
|
931
|
+
cdp_url: str = typer.Option(
|
|
932
|
+
"http://127.0.0.1:9222",
|
|
933
|
+
"--cdp-url",
|
|
934
|
+
help="CDP endpoint to probe (info only)",
|
|
935
|
+
),
|
|
936
|
+
) -> None:
|
|
937
|
+
"""Diagnose browser stack used by `web2cli login --browser`."""
|
|
938
|
+
failures = 0
|
|
939
|
+
warnings = 0
|
|
940
|
+
|
|
941
|
+
err.print("\n[bold]Browser Doctor[/bold]\n")
|
|
942
|
+
|
|
943
|
+
chrome_path = find_local_chrome_executable()
|
|
944
|
+
if chrome_path:
|
|
945
|
+
_print_doctor_status("ok", "chrome", chrome_path)
|
|
946
|
+
else:
|
|
947
|
+
_print_doctor_status("warn", "chrome", "not found in standard locations/PATH")
|
|
948
|
+
warnings += 1
|
|
949
|
+
|
|
950
|
+
cdp_running = probe_cdp_endpoint(cdp_url, timeout_seconds=1.0)
|
|
951
|
+
if cdp_running:
|
|
952
|
+
_print_doctor_status("ok", "cdp-endpoint", f"reachable at {cdp_url}")
|
|
953
|
+
else:
|
|
954
|
+
_print_doctor_status("warn", "cdp-endpoint", f"not reachable at {cdp_url}")
|
|
955
|
+
warnings += 1
|
|
956
|
+
|
|
957
|
+
playwright_async_api = None
|
|
958
|
+
try:
|
|
959
|
+
from playwright import async_api as playwright_async_api # type: ignore[assignment]
|
|
960
|
+
|
|
961
|
+
_print_doctor_status("ok", "playwright-python", "importable")
|
|
962
|
+
except Exception as e:
|
|
963
|
+
_print_doctor_status("fail", "playwright-python", str(e))
|
|
964
|
+
failures += 1
|
|
965
|
+
|
|
966
|
+
if deep:
|
|
967
|
+
err.print("\n[bold]Deep checks[/bold]")
|
|
968
|
+
|
|
969
|
+
auto_session: AutoCdpSession | None = None
|
|
970
|
+
try:
|
|
971
|
+
auto_session = start_auto_cdp_chrome(
|
|
972
|
+
status_cb=None,
|
|
973
|
+
debug_cb=None,
|
|
974
|
+
headless=True,
|
|
975
|
+
)
|
|
976
|
+
_print_doctor_status("ok", "cdp-auto-launch", auto_session.cdp_url)
|
|
977
|
+
if probe_cdp_endpoint(auto_session.cdp_url, timeout_seconds=1.5):
|
|
978
|
+
_print_doctor_status("ok", "cdp-auto-probe", "endpoint responded")
|
|
979
|
+
else:
|
|
980
|
+
_print_doctor_status("fail", "cdp-auto-probe", "endpoint did not respond")
|
|
981
|
+
failures += 1
|
|
982
|
+
except Exception as e:
|
|
983
|
+
_print_doctor_status("fail", "cdp-auto-launch", _doctor_error_summary(e))
|
|
984
|
+
failures += 1
|
|
985
|
+
finally:
|
|
986
|
+
if auto_session is not None:
|
|
987
|
+
stop_auto_cdp_chrome(auto_session)
|
|
988
|
+
|
|
989
|
+
if playwright_async_api is not None:
|
|
990
|
+
async def _probe_playwright_launch() -> None:
|
|
991
|
+
async with playwright_async_api.async_playwright() as p:
|
|
992
|
+
browser = await p.chromium.launch(
|
|
993
|
+
headless=True,
|
|
994
|
+
ignore_default_args=["--enable-automation", "--no-sandbox"],
|
|
995
|
+
)
|
|
996
|
+
await browser.close()
|
|
997
|
+
|
|
998
|
+
try:
|
|
999
|
+
asyncio.run(_probe_playwright_launch())
|
|
1000
|
+
_print_doctor_status("ok", "playwright-launch", "chromium launched headless")
|
|
1001
|
+
except Exception as e:
|
|
1002
|
+
_print_doctor_status("fail", "playwright-launch", _doctor_error_summary(e))
|
|
1003
|
+
failures += 1
|
|
1004
|
+
|
|
1005
|
+
err.print()
|
|
1006
|
+
if failures:
|
|
1007
|
+
err.print(
|
|
1008
|
+
f"[red]browser doctor failed: {failures} failure(s), {warnings} warning(s)[/red]"
|
|
1009
|
+
)
|
|
1010
|
+
raise typer.Exit(1)
|
|
1011
|
+
|
|
1012
|
+
if warnings:
|
|
1013
|
+
err.print(
|
|
1014
|
+
f"[yellow]browser doctor passed with warnings ({warnings})[/yellow]"
|
|
1015
|
+
)
|
|
1016
|
+
else:
|
|
1017
|
+
err.print("[green]browser doctor passed[/green]")
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
# ---------------------------------------------------------------------------
|
|
1021
|
+
# Subcommand: web2cli login / logout
|
|
1022
|
+
# ---------------------------------------------------------------------------
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
@app.command("login")
|
|
1026
|
+
def login_command(
|
|
1027
|
+
ctx: typer.Context,
|
|
1028
|
+
domain: str | None = typer.Argument(
|
|
1029
|
+
None,
|
|
1030
|
+
help="Domain or alias to authenticate",
|
|
1031
|
+
),
|
|
1032
|
+
cookies: str = typer.Option(None, "--cookies", help='Cookies string "k=v; k2=v2"'),
|
|
1033
|
+
cookie_file: str = typer.Option(None, "--cookie-file", help="Path to cookies JSON"),
|
|
1034
|
+
token: str = typer.Option(None, "--token", help="Auth token"),
|
|
1035
|
+
browser: bool = typer.Option(
|
|
1036
|
+
False,
|
|
1037
|
+
"--browser",
|
|
1038
|
+
help="Open browser and capture required auth values automatically",
|
|
1039
|
+
),
|
|
1040
|
+
browser_debug: bool = typer.Option(
|
|
1041
|
+
False,
|
|
1042
|
+
"--browser-debug",
|
|
1043
|
+
help="Show browser auth-capture debug (cookies/token/tabs)",
|
|
1044
|
+
),
|
|
1045
|
+
browser_cdp_url: str = typer.Option(
|
|
1046
|
+
None,
|
|
1047
|
+
"--browser-cdp-url",
|
|
1048
|
+
help="Attach to existing Chrome via CDP (e.g. http://127.0.0.1:9222)",
|
|
1049
|
+
hidden=True,
|
|
1050
|
+
),
|
|
1051
|
+
browser_cdp_auto: bool = typer.Option(
|
|
1052
|
+
False,
|
|
1053
|
+
"--browser-cdp-auto",
|
|
1054
|
+
help="Start local Chrome automatically and attach via CDP",
|
|
1055
|
+
hidden=True,
|
|
1056
|
+
),
|
|
1057
|
+
browser_cdp_port: int = typer.Option(
|
|
1058
|
+
None,
|
|
1059
|
+
"--browser-cdp-port",
|
|
1060
|
+
help="CDP port for --browser-cdp-auto (default: random free port)",
|
|
1061
|
+
hidden=True,
|
|
1062
|
+
),
|
|
1063
|
+
browser_chrome_path: str = typer.Option(
|
|
1064
|
+
None,
|
|
1065
|
+
"--browser-chrome-path",
|
|
1066
|
+
help="Chrome executable path for --browser-cdp-auto",
|
|
1067
|
+
hidden=True,
|
|
1068
|
+
),
|
|
1069
|
+
status: bool = typer.Option(False, "--status", help="Check login status"),
|
|
1070
|
+
) -> None:
|
|
1071
|
+
"""Save authentication session for a domain.
|
|
1072
|
+
|
|
1073
|
+
Examples:
|
|
1074
|
+
web2cli login hn --browser
|
|
1075
|
+
web2cli login slack --browser --browser-debug
|
|
1076
|
+
web2cli login reddit --cookies "reddit_session=..."
|
|
1077
|
+
web2cli login discord --token "..."
|
|
1078
|
+
web2cli login x --status
|
|
1079
|
+
web2cli login hn # show adapter-specific auth guide
|
|
1080
|
+
"""
|
|
1081
|
+
if not domain:
|
|
1082
|
+
err.print(ctx.get_help())
|
|
1083
|
+
raise typer.Exit(0)
|
|
1084
|
+
|
|
1085
|
+
# Resolve alias → adapter domain
|
|
1086
|
+
adapter: AdapterSpec | None = None
|
|
1087
|
+
try:
|
|
1088
|
+
adapter = load_adapter(domain)
|
|
1089
|
+
resolved_domain = adapter.meta.domain
|
|
1090
|
+
except AdapterNotFound:
|
|
1091
|
+
resolved_domain = domain
|
|
1092
|
+
|
|
1093
|
+
# --status: show session info and exit
|
|
1094
|
+
if status:
|
|
1095
|
+
info = check_session(resolved_domain)
|
|
1096
|
+
if not info.get("exists"):
|
|
1097
|
+
err.print(f"[yellow]No session for {resolved_domain}[/yellow]")
|
|
1098
|
+
raise typer.Exit(1)
|
|
1099
|
+
err.print(f"[green]Logged in to {resolved_domain}[/green]")
|
|
1100
|
+
err.print(f" type: {info.get('auth_type', '?')}")
|
|
1101
|
+
if info.get("cookie_keys"):
|
|
1102
|
+
err.print(f" cookies: {', '.join(info['cookie_keys'])}")
|
|
1103
|
+
if info.get("has_token"):
|
|
1104
|
+
err.print(" token: present")
|
|
1105
|
+
if info.get("created_at"):
|
|
1106
|
+
err.print(f" created: {info['created_at']}")
|
|
1107
|
+
raise typer.Exit(0)
|
|
1108
|
+
|
|
1109
|
+
if browser and (cookies or cookie_file or token):
|
|
1110
|
+
err.print(
|
|
1111
|
+
"[red]--browser cannot be combined with --cookies, --cookie-file, or --token[/red]"
|
|
1112
|
+
)
|
|
1113
|
+
raise typer.Exit(1)
|
|
1114
|
+
if browser_cdp_url and not browser:
|
|
1115
|
+
err.print("[red]--browser-cdp-url requires --browser[/red]")
|
|
1116
|
+
raise typer.Exit(1)
|
|
1117
|
+
if browser_cdp_auto and not browser:
|
|
1118
|
+
err.print("[red]--browser-cdp-auto requires --browser[/red]")
|
|
1119
|
+
raise typer.Exit(1)
|
|
1120
|
+
if browser_cdp_url and browser_cdp_auto:
|
|
1121
|
+
err.print("[red]Use only one: --browser-cdp-url or --browser-cdp-auto[/red]")
|
|
1122
|
+
raise typer.Exit(1)
|
|
1123
|
+
if browser_cdp_port is not None and not browser:
|
|
1124
|
+
err.print("[red]--browser-cdp-port requires --browser[/red]")
|
|
1125
|
+
raise typer.Exit(1)
|
|
1126
|
+
if browser_chrome_path and not browser:
|
|
1127
|
+
err.print("[red]--browser-chrome-path requires --browser[/red]")
|
|
1128
|
+
raise typer.Exit(1)
|
|
1129
|
+
if browser_cdp_url and browser_cdp_port is not None:
|
|
1130
|
+
err.print("[red]--browser-cdp-port cannot be used with --browser-cdp-url[/red]")
|
|
1131
|
+
raise typer.Exit(1)
|
|
1132
|
+
if browser_cdp_url and browser_chrome_path:
|
|
1133
|
+
err.print("[red]--browser-chrome-path cannot be used with --browser-cdp-url[/red]")
|
|
1134
|
+
raise typer.Exit(1)
|
|
1135
|
+
if browser_debug and not browser:
|
|
1136
|
+
err.print("[red]--browser-debug requires --browser[/red]")
|
|
1137
|
+
raise typer.Exit(1)
|
|
1138
|
+
|
|
1139
|
+
if browser:
|
|
1140
|
+
if adapter is None:
|
|
1141
|
+
err.print(
|
|
1142
|
+
f"[red]No adapter found for '{domain}'. --browser requires a known adapter "
|
|
1143
|
+
"with browser auth capture config.[/red]"
|
|
1144
|
+
)
|
|
1145
|
+
raise typer.Exit(1)
|
|
1146
|
+
|
|
1147
|
+
required_cookie_keys = _cookie_keys_from_auth_spec(adapter.auth)
|
|
1148
|
+
token_capture_rules = _token_capture_rules_from_auth_spec(adapter.auth)
|
|
1149
|
+
if not required_cookie_keys and not token_capture_rules:
|
|
1150
|
+
err.print(
|
|
1151
|
+
f"[red]{resolved_domain} does not declare browser-capturable auth in auth.methods "
|
|
1152
|
+
"(cookie keys and/or token capture rules)[/red]"
|
|
1153
|
+
)
|
|
1154
|
+
raise typer.Exit(1)
|
|
1155
|
+
|
|
1156
|
+
err.print(f"\n[bold]Logging into {resolved_domain}[/bold]\n")
|
|
1157
|
+
err.print(" Opening browser...")
|
|
1158
|
+
err.print(f" → Log in to {resolved_domain} if needed")
|
|
1159
|
+
err.print(" → I'll capture required auth values automatically when ready")
|
|
1160
|
+
if browser_cdp_url:
|
|
1161
|
+
err.print(f" → Using existing browser via CDP: {browser_cdp_url}")
|
|
1162
|
+
err.print(" → Press Ctrl+C to cancel\n")
|
|
1163
|
+
err.print(" ⏳ Waiting for login... (detected: not logged in)")
|
|
1164
|
+
if browser_debug:
|
|
1165
|
+
err.print(" [dim]Browser debug enabled[/dim]")
|
|
1166
|
+
|
|
1167
|
+
try:
|
|
1168
|
+
parsed_cookies, captured_token = capture_auth_with_browser(
|
|
1169
|
+
domain=resolved_domain,
|
|
1170
|
+
required_cookies=required_cookie_keys,
|
|
1171
|
+
token_rules=token_capture_rules,
|
|
1172
|
+
status_cb=lambda msg: err.print(f" {msg}"),
|
|
1173
|
+
debug_cb=(
|
|
1174
|
+
(lambda msg: err.print(f" [dim]browser-debug:[/dim] {escape(msg)}"))
|
|
1175
|
+
if browser_debug
|
|
1176
|
+
else None
|
|
1177
|
+
),
|
|
1178
|
+
cdp_url=browser_cdp_url,
|
|
1179
|
+
cdp_auto=browser_cdp_auto,
|
|
1180
|
+
cdp_port=browser_cdp_port,
|
|
1181
|
+
chrome_path=browser_chrome_path,
|
|
1182
|
+
)
|
|
1183
|
+
except BrowserLoginCancelled:
|
|
1184
|
+
err.print("[yellow]Login cancelled.[/yellow]")
|
|
1185
|
+
raise typer.Exit(1)
|
|
1186
|
+
except BrowserLoginError as e:
|
|
1187
|
+
err.print(f"[red]{e}[/red]")
|
|
1188
|
+
raise typer.Exit(1)
|
|
1189
|
+
|
|
1190
|
+
err.print(" ✓ Login detected! Capturing session...")
|
|
1191
|
+
create_session(
|
|
1192
|
+
resolved_domain,
|
|
1193
|
+
cookies=parsed_cookies or None,
|
|
1194
|
+
token=captured_token,
|
|
1195
|
+
)
|
|
1196
|
+
session_path = SESSIONS_DIR / f"{resolved_domain}.json.enc"
|
|
1197
|
+
auth_parts: list[str] = []
|
|
1198
|
+
if parsed_cookies:
|
|
1199
|
+
auth_parts.append("cookies")
|
|
1200
|
+
if captured_token:
|
|
1201
|
+
auth_parts.append("token")
|
|
1202
|
+
auth_label = "+".join(auth_parts) if auth_parts else "auth"
|
|
1203
|
+
err.print(f" ✓ {auth_label.capitalize()} encrypted and saved to {session_path}")
|
|
1204
|
+
err.print()
|
|
1205
|
+
err.print(" Try it now:")
|
|
1206
|
+
run_target = adapter.meta.aliases[0] if adapter.meta.aliases else adapter.meta.domain
|
|
1207
|
+
if "search" in adapter.commands:
|
|
1208
|
+
sample_cmd = "search"
|
|
1209
|
+
elif adapter.commands:
|
|
1210
|
+
sample_cmd = next(iter(adapter.commands))
|
|
1211
|
+
else:
|
|
1212
|
+
sample_cmd = "--help"
|
|
1213
|
+
err.print(f" web2cli {run_target} {sample_cmd}")
|
|
1214
|
+
raise typer.Exit(0)
|
|
1215
|
+
|
|
1216
|
+
# Parse cookies from string or file
|
|
1217
|
+
parsed_cookies = None
|
|
1218
|
+
if cookies:
|
|
1219
|
+
parsed_cookies = parse_cookie_string(cookies)
|
|
1220
|
+
elif cookie_file:
|
|
1221
|
+
try:
|
|
1222
|
+
parsed_cookies = parse_cookie_file(cookie_file)
|
|
1223
|
+
except (OSError, ValueError) as e:
|
|
1224
|
+
err.print(f"[red]Failed to read cookie file: {e}[/red]")
|
|
1225
|
+
raise typer.Exit(1)
|
|
1226
|
+
|
|
1227
|
+
if not parsed_cookies and not token:
|
|
1228
|
+
err.print("[red]Provide one of: --cookies, --cookie-file, --token, or --browser[/red]")
|
|
1229
|
+
if adapter is not None:
|
|
1230
|
+
login_target = adapter.meta.aliases[0] if adapter.meta.aliases else domain
|
|
1231
|
+
_print_login_auth_guide(
|
|
1232
|
+
adapter,
|
|
1233
|
+
resolved_domain=resolved_domain,
|
|
1234
|
+
login_target=login_target,
|
|
1235
|
+
)
|
|
1236
|
+
raise typer.Exit(1)
|
|
1237
|
+
|
|
1238
|
+
# Warn about missing keys if adapter has an auth spec
|
|
1239
|
+
if adapter and adapter.auth and parsed_cookies:
|
|
1240
|
+
for method in adapter.auth.get("methods", []):
|
|
1241
|
+
expected = method.get("keys", [])
|
|
1242
|
+
missing = [k for k in expected if k not in parsed_cookies]
|
|
1243
|
+
if missing:
|
|
1244
|
+
err.print(
|
|
1245
|
+
f"[yellow]Warning: adapter expects cookie keys "
|
|
1246
|
+
f"{missing} but they were not provided[/yellow]"
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
# Save session
|
|
1250
|
+
session = create_session(resolved_domain, cookies=parsed_cookies, token=token)
|
|
1251
|
+
err.print(f"[green]Session saved for {resolved_domain} ({session.auth_type})[/green]")
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
@app.command("logout")
|
|
1255
|
+
def logout_command(
|
|
1256
|
+
domain: str = typer.Argument(
|
|
1257
|
+
...,
|
|
1258
|
+
help="Domain or alias to log out",
|
|
1259
|
+
),
|
|
1260
|
+
) -> None:
|
|
1261
|
+
"""Remove stored session for a domain."""
|
|
1262
|
+
try:
|
|
1263
|
+
adapter = load_adapter(domain)
|
|
1264
|
+
resolved_domain = adapter.meta.domain
|
|
1265
|
+
except AdapterNotFound:
|
|
1266
|
+
resolved_domain = domain
|
|
1267
|
+
|
|
1268
|
+
if remove_session(resolved_domain):
|
|
1269
|
+
err.print(f"[green]Session removed for {resolved_domain}[/green]")
|
|
1270
|
+
else:
|
|
1271
|
+
err.print(f"[yellow]No session found for {resolved_domain}[/yellow]")
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
# ---------------------------------------------------------------------------
|
|
1275
|
+
# Version callback
|
|
1276
|
+
# ---------------------------------------------------------------------------
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
@app.callback(invoke_without_command=True)
|
|
1280
|
+
def main(
|
|
1281
|
+
ctx: typer.Context,
|
|
1282
|
+
version: bool = typer.Option(False, "--version", "-v", help="Show version"),
|
|
1283
|
+
) -> None:
|
|
1284
|
+
if version:
|
|
1285
|
+
typer.echo(f"web2cli {__version__}")
|
|
1286
|
+
raise typer.Exit()
|