modao-prd-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.
Files changed (32) hide show
  1. modao_prd_cli/__init__.py +1 -0
  2. modao_prd_cli/modao_prd/__init__.py +4 -0
  3. modao_prd_cli/modao_prd/__main__.py +7 -0
  4. modao_prd_cli/modao_prd/browser.py +578 -0
  5. modao_prd_cli/modao_prd/capture_evidence.py +547 -0
  6. modao_prd_cli/modao_prd/classifier.py +96 -0
  7. modao_prd_cli/modao_prd/cli.py +205 -0
  8. modao_prd_cli/modao_prd/errors.py +31 -0
  9. modao_prd_cli/modao_prd/evidence.py +207 -0
  10. modao_prd_cli/modao_prd/explorer.py +300 -0
  11. modao_prd_cli/modao_prd/extractor.py +465 -0
  12. modao_prd_cli/modao_prd/models.py +56 -0
  13. modao_prd_cli/modao_prd/normalizer.py +135 -0
  14. modao_prd_cli/modao_prd/schemas/coverage-1.0.json +15 -0
  15. modao_prd_cli/modao_prd/schemas/document-2.0.json +21 -0
  16. modao_prd_cli/modao_prd/schemas/document-2.1.json +31 -0
  17. modao_prd_cli/modao_prd/schemas/manifest-1.0.json +28 -0
  18. modao_prd_cli/modao_prd/tests/__init__.py +1 -0
  19. modao_prd_cli/modao_prd/tests/fixtures/modao_sample.html +20 -0
  20. modao_prd_cli/modao_prd/tests/test_browser.py +54 -0
  21. modao_prd_cli/modao_prd/tests/test_classifier.py +32 -0
  22. modao_prd_cli/modao_prd/tests/test_cli.py +88 -0
  23. modao_prd_cli/modao_prd/tests/test_extractor.py +57 -0
  24. modao_prd_cli/modao_prd/tests/test_full_e2e.py +23 -0
  25. modao_prd_cli/modao_prd/tests/test_writers.py +79 -0
  26. modao_prd_cli/modao_prd/writers.py +468 -0
  27. modao_prd_cli-0.1.0.dist-info/METADATA +108 -0
  28. modao_prd_cli-0.1.0.dist-info/RECORD +32 -0
  29. modao_prd_cli-0.1.0.dist-info/WHEEL +5 -0
  30. modao_prd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  31. modao_prd_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
  32. modao_prd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,205 @@
1
+ """Command-line interface for extracting public Modao prototype shares."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from . import __version__
13
+ from .browser import (
14
+ capture_page,
15
+ default_output_root,
16
+ doctor,
17
+ ensure_default_output_ignored,
18
+ validate_share_url,
19
+ )
20
+ from .evidence import EvidenceBundleWriter
21
+ from .errors import ModaoPrdError
22
+ from .extractor import build_document, summarize_document
23
+ from .writers import write_outputs
24
+
25
+
26
+ def _json_mode(ctx: click.Context, command_json: bool) -> bool:
27
+ return bool(command_json or (ctx.find_root().obj or {}).get("json", False))
28
+
29
+
30
+ def _emit(data: Any, *, as_json: bool, message: str | None = None) -> None:
31
+ if as_json:
32
+ click.echo(json.dumps(data, ensure_ascii=False, indent=2, default=str))
33
+ return
34
+ if message:
35
+ click.echo(message)
36
+ if isinstance(data, dict):
37
+ _print_dict(data)
38
+ elif isinstance(data, list):
39
+ for item in data:
40
+ click.echo(f"- {item}")
41
+ else:
42
+ click.echo(str(data))
43
+
44
+
45
+ def _print_dict(data: dict[str, Any], indent: int = 0) -> None:
46
+ prefix = " " * indent
47
+ for key, value in data.items():
48
+ if isinstance(value, dict):
49
+ click.echo(f"{prefix}{key}:")
50
+ _print_dict(value, indent + 1)
51
+ elif isinstance(value, list):
52
+ click.echo(f"{prefix}{key}:")
53
+ for item in value:
54
+ if isinstance(item, dict):
55
+ click.echo(f"{prefix} -")
56
+ _print_dict(item, indent + 2)
57
+ else:
58
+ click.echo(f"{prefix} - {item}")
59
+ else:
60
+ click.echo(f"{prefix}{key}: {value}")
61
+
62
+
63
+ def _fail(exc: Exception, *, as_json: bool, url: str | None = None) -> None:
64
+ if isinstance(exc, ModaoPrdError):
65
+ payload = exc.as_dict()
66
+ elif isinstance(exc, FileExistsError):
67
+ payload = ModaoPrdError("output_exists", str(exc), url=url).as_dict()
68
+ elif isinstance(exc, ValueError):
69
+ payload = ModaoPrdError("invalid_input", str(exc), url=url).as_dict()
70
+ else:
71
+ payload = ModaoPrdError("runtime_error", str(exc), url=url).as_dict()
72
+ if as_json:
73
+ click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
74
+ else:
75
+ click.echo(f"Error: {payload['error']['message']}", err=True)
76
+ raise click.exceptions.Exit(1)
77
+
78
+
79
+ @click.group(invoke_without_command=True)
80
+ @click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
81
+ @click.version_option(version=__version__, prog_name="modao-prd-cli")
82
+ @click.pass_context
83
+ def cli(ctx: click.Context, json_output: bool) -> None:
84
+ """将墨刀公开分享页提取为 Agent 易读取的结构化文档。"""
85
+
86
+ ctx.ensure_object(dict)
87
+ ctx.obj["json"] = json_output
88
+ if ctx.invoked_subcommand is None:
89
+ click.echo(ctx.get_help())
90
+
91
+
92
+ @cli.command("doctor")
93
+ @click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
94
+ @click.pass_context
95
+ def doctor_cmd(ctx: click.Context, json_output: bool) -> None:
96
+ """检查 Python、Playwright 和浏览器运行环境。"""
97
+
98
+ _emit(doctor(), as_json=_json_mode(ctx, json_output))
99
+
100
+
101
+ @cli.command("inspect")
102
+ @click.argument("url")
103
+ @click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
104
+ @click.option("--headed", is_flag=True, help="显示浏览器窗口,用于调试页面渲染。")
105
+ @click.option("--timeout", type=click.IntRange(min=1), default=30, show_default=True, help="页面渲染超时时间(秒)。")
106
+ @click.pass_context
107
+ def inspect_cmd(ctx: click.Context, url: str, json_output: bool, headed: bool, timeout: int) -> None:
108
+ """读取 URL 并输出页面提取摘要,不生成文件。"""
109
+
110
+ as_json = _json_mode(ctx, json_output)
111
+ try:
112
+ raw = capture_page(url, headed=headed, timeout_seconds=timeout)
113
+ document = build_document(raw)
114
+ _emit(summarize_document(document), as_json=as_json, message="墨刀页面检查完成。")
115
+ except Exception as exc:
116
+ _fail(exc, as_json=as_json, url=url)
117
+
118
+
119
+ @cli.command("export")
120
+ @click.argument("url")
121
+ @click.option("--format", "output_format", type=click.Choice(["json", "markdown", "ndjson", "all"]), default="all", show_default=True, help="导出格式。")
122
+ @click.option("--output", "output_root", type=click.Path(file_okay=False, dir_okay=True, path_type=str), default=None, help="输出根目录;默认是当前项目根目录的 .modao-prd,项目 ID 会作为子目录。")
123
+ @click.option("--headed", is_flag=True, help="显示浏览器窗口,用于调试页面渲染。")
124
+ @click.option("--timeout", type=click.IntRange(min=1), default=30, show_default=True, help="页面渲染超时时间(秒)。")
125
+ @click.option("--force", is_flag=True, help="覆盖已有 document 输出文件。")
126
+ @click.option("--evidence", type=click.Choice(["essential", "full", "none"]), default="essential", show_default=True, help="证据采集级别;essential 适合需求分析,full 适合排障。")
127
+ @click.option("--explore", type=click.Choice(["safe", "none"]), default="safe", show_default=True, help="安全状态探索策略。")
128
+ @click.option("--max-states", type=click.IntRange(min=1), default=50, show_default=True, help="最多采集状态数。")
129
+ @click.option("--max-depth", type=click.IntRange(min=0), default=3, show_default=True, help="安全探索最大深度。")
130
+ @click.option("--max-actions", type=click.IntRange(min=0), default=200, show_default=True, help="最多尝试动作数。")
131
+ @click.option("--max-duration", type=click.IntRange(min=1), default=120, show_default=True, help="整体采集上限(秒)。")
132
+ @click.option("--max-item-mb", type=click.IntRange(min=0), default=10, show_default=True, help="单个证据文件大小上限(MiB)。")
133
+ @click.option("--max-total-mb", type=click.IntRange(min=0), default=200, show_default=True, help="证据包大小上限(MiB)。")
134
+ @click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
135
+ @click.pass_context
136
+ def export_cmd(
137
+ ctx: click.Context,
138
+ url: str,
139
+ output_format: str,
140
+ output_root: str | None,
141
+ headed: bool,
142
+ timeout: int,
143
+ force: bool,
144
+ evidence: str,
145
+ explore: str,
146
+ max_states: int,
147
+ max_depth: int,
148
+ max_actions: int,
149
+ max_duration: int,
150
+ max_item_mb: int,
151
+ max_total_mb: int,
152
+ json_output: bool,
153
+ ) -> None:
154
+ """读取墨刀公开分享页并导出 JSON、Markdown 或 NDJSON。"""
155
+
156
+ as_json = _json_mode(ctx, json_output)
157
+ writer: EvidenceBundleWriter | None = None
158
+ try:
159
+ share = validate_share_url(url)
160
+ if output_root is None:
161
+ root_path = default_output_root()
162
+ ensure_default_output_ignored(root_path.parent)
163
+ root = str(root_path)
164
+ else:
165
+ root = output_root
166
+ output_dir = Path(root).expanduser().resolve() / share.project_id
167
+ if evidence != "none":
168
+ output_dir.parent.mkdir(parents=True, exist_ok=True)
169
+ writer = EvidenceBundleWriter(
170
+ output_dir,
171
+ max_item=max_item_mb * 1024 * 1024,
172
+ max_total=max_total_mb * 1024 * 1024,
173
+ force=force,
174
+ )
175
+ raw = capture_page(
176
+ url,
177
+ headed=headed,
178
+ timeout_seconds=timeout,
179
+ evidence_writer=writer,
180
+ evidence_mode=evidence,
181
+ explore=explore,
182
+ max_states=max_states,
183
+ max_depth=max_depth,
184
+ max_actions=max_actions,
185
+ max_duration_seconds=max_duration,
186
+ max_item=max_item_mb * 1024 * 1024,
187
+ max_total=max_total_mb * 1024 * 1024,
188
+ )
189
+ document = build_document(raw)
190
+ result = write_outputs(document, root, output_format, force=force, evidence_writer=writer)
191
+ writer = None
192
+ result["summary"] = summarize_document(document)
193
+ _emit(result, as_json=as_json, message="墨刀文档导出完成。")
194
+ if document.get("stats", {}).get("quality") == "partial":
195
+ raise click.exceptions.Exit(2)
196
+ except Exception as exc:
197
+ if writer is not None:
198
+ writer.close()
199
+ if isinstance(exc, click.exceptions.Exit):
200
+ raise
201
+ _fail(exc, as_json=as_json, url=url)
202
+
203
+
204
+ def main() -> None:
205
+ cli(prog_name="modao-prd-cli")
@@ -0,0 +1,31 @@
1
+ """Stable, JSON-safe error types used by the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class ModaoPrdError(Exception):
11
+ """An expected failure with a stable machine-readable type."""
12
+
13
+ error_type: str
14
+ message: str
15
+ url: str | None = None
16
+ diagnostics: dict[str, Any] = field(default_factory=dict)
17
+
18
+ def __post_init__(self) -> None:
19
+ super().__init__(self.message)
20
+
21
+ def as_dict(self) -> dict[str, Any]:
22
+ error: dict[str, Any] = {
23
+ "type": self.error_type,
24
+ "message": self.message,
25
+ }
26
+ if self.url:
27
+ error["url"] = self.url
28
+ if self.diagnostics:
29
+ error["diagnostics"] = self.diagnostics
30
+ return {"error": error}
31
+
@@ -0,0 +1,207 @@
1
+ """Safe, bounded and atomic evidence bundle writing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from dataclasses import asdict
12
+ from pathlib import Path
13
+ from typing import Any, BinaryIO
14
+
15
+ from .models import EvidenceItem
16
+
17
+
18
+ class EvidenceBundleWriter:
19
+ """Stage evidence files and atomically publish a manifest-backed bundle.
20
+
21
+ ``output_dir`` is the bundle directory. Files are accepted only as relative
22
+ paths, and an existing file may be replaced with ``force`` only when the
23
+ previous manifest identifies it as a generated evidence file.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ output_dir: str | Path,
29
+ *,
30
+ max_item: int = 10 * 1024 * 1024,
31
+ max_total: int = 200 * 1024 * 1024,
32
+ force: bool = False,
33
+ generated_by: str = "modao-prd-cli",
34
+ ) -> None:
35
+ if max_item < 0 or max_total < 0:
36
+ raise ValueError("size limits must be non-negative")
37
+ self.output_dir = Path(output_dir).expanduser().resolve()
38
+ self.max_item = max_item
39
+ self.max_total = max_total
40
+ self.force = force
41
+ self.generated_by = generated_by
42
+ self._items: list[EvidenceItem] = []
43
+ self._total = 0
44
+ self._old_generated = self._read_old_generated()
45
+ self._temp = Path(tempfile.mkdtemp(prefix=f".{self.output_dir.name}.", dir=str(self.output_dir.parent)))
46
+ self._closed = False
47
+
48
+ def add(
49
+ self,
50
+ relative_path: str | Path,
51
+ content: bytes | bytearray | memoryview | str | Path | BinaryIO,
52
+ *,
53
+ media_type: str | None = None,
54
+ enforce_item_limit: bool = True,
55
+ ) -> EvidenceItem:
56
+ """Stage one item and return its manifest record."""
57
+ if self._closed:
58
+ raise RuntimeError("bundle writer is already finalized")
59
+ try:
60
+ relative = self._validate_relative_path(relative_path)
61
+ path_key = self._path_key(relative)
62
+ if path_key == "manifest.json":
63
+ raise ValueError("manifest.json is reserved")
64
+ if any(self._path_key(item.path) == path_key for item in self._items):
65
+ raise ValueError(f"duplicate evidence path: {relative.as_posix()}")
66
+ destination = self._temp / relative
67
+ destination.parent.mkdir(parents=True, exist_ok=True)
68
+ size = 0
69
+ digest = hashlib.sha256()
70
+ with destination.open("wb") as target:
71
+ for chunk in self._chunks(content):
72
+ size += len(chunk)
73
+ if (enforce_item_limit and size > self.max_item) or self._total + size > self.max_total:
74
+ raise ValueError("evidence size limit exceeded")
75
+ digest.update(chunk)
76
+ target.write(chunk)
77
+ self._total += size
78
+ item = EvidenceItem(relative.as_posix(), digest.hexdigest(), size, media_type or self._media_type(relative))
79
+ self._items.append(item)
80
+ return item
81
+ except Exception:
82
+ # Keep the staged bundle alive so the caller can record a partial
83
+ # capture and still publish other evidence files. Finalization (or
84
+ # the caller's explicit close) performs the cleanup.
85
+ try:
86
+ if destination.exists():
87
+ destination.unlink()
88
+ except UnboundLocalError:
89
+ pass
90
+ raise
91
+
92
+ def finalize(self, *, extra: dict[str, Any] | None = None) -> Path:
93
+ """Publish staged files and an atomically replaced ``manifest.json``."""
94
+ if self._closed:
95
+ raise RuntimeError("bundle writer is already finalized")
96
+ try:
97
+ self.output_dir.mkdir(parents=True, exist_ok=True)
98
+ existing_manifest = self.output_dir / "manifest.json"
99
+ self._assert_inside_output(existing_manifest)
100
+ existing_files = {self._path_key(path) for path in self._old_generated}
101
+ targets = []
102
+ for item in self._items:
103
+ target = self.output_dir / item.path
104
+ self._assert_inside_output(target)
105
+ if target.exists() and not (self.force and self._path_key(item.path) in existing_files):
106
+ raise FileExistsError(f"evidence file already exists: {target}")
107
+ targets.append(target)
108
+
109
+ manifest: dict[str, Any] = {
110
+ "schema_version": "1.0",
111
+ "generated_by": self.generated_by,
112
+ "files": [asdict(item) for item in self._items],
113
+ "total_size": self._total,
114
+ }
115
+ if extra:
116
+ manifest["metadata"] = dict(extra)
117
+ for item in self._items:
118
+ target = targets.pop(0)
119
+ target.parent.mkdir(parents=True, exist_ok=True)
120
+ os.replace(self._temp / item.path, target)
121
+ manifest_tmp = self._temp / "manifest.json"
122
+ manifest_tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
123
+ os.replace(manifest_tmp, existing_manifest)
124
+ self._remove_stale_generated()
125
+ return existing_manifest
126
+ finally:
127
+ self.close()
128
+
129
+ def _read_old_generated(self) -> set[str]:
130
+ manifest = Path(self.output_dir) / "manifest.json"
131
+ try:
132
+ data = json.loads(manifest.read_text(encoding="utf-8"))
133
+ except (FileNotFoundError, OSError, ValueError):
134
+ return set()
135
+ if not isinstance(data, dict) or not isinstance(data.get("files"), list):
136
+ return set()
137
+ return {
138
+ str(entry["path"])
139
+ for entry in data.get("files", [])
140
+ if isinstance(entry, dict) and isinstance(entry.get("path"), str)
141
+ }
142
+
143
+ def _remove_stale_generated(self) -> None:
144
+ """Remove obsolete evidence files previously generated by this tool."""
145
+
146
+ if not self.force:
147
+ return
148
+ current = {self._path_key(item.path) for item in self._items}
149
+ for old_path in self._old_generated:
150
+ if self._path_key(old_path) in current or not old_path.casefold().startswith("evidence/"):
151
+ continue
152
+ target = self.output_dir / old_path
153
+ try:
154
+ self._assert_inside_output(target)
155
+ if target.is_file() or target.is_symlink():
156
+ target.unlink()
157
+ except (FileNotFoundError, OSError, ValueError):
158
+ # A stale file is cleanup-only; never turn a successful bundle
159
+ # into a failed export because an old artifact disappeared.
160
+ continue
161
+
162
+ @staticmethod
163
+ def _path_key(path: str | Path) -> str:
164
+ return Path(path).as_posix().casefold()
165
+
166
+ def _assert_inside_output(self, path: Path) -> None:
167
+ try:
168
+ path.resolve(strict=False).relative_to(self.output_dir)
169
+ except ValueError as exc:
170
+ raise ValueError(f"evidence target escapes output directory: {path}") from exc
171
+
172
+ def close(self) -> None:
173
+ """Discard staged files and make the writer unusable."""
174
+ if not self._closed:
175
+ self._closed = True
176
+ shutil.rmtree(self._temp, ignore_errors=True)
177
+
178
+ def __enter__(self) -> "EvidenceBundleWriter":
179
+ if self._closed:
180
+ raise RuntimeError("bundle writer is already finalized")
181
+ return self
182
+
183
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
184
+ self.close()
185
+
186
+ @staticmethod
187
+ def _validate_relative_path(path: str | Path) -> Path:
188
+ candidate = Path(path)
189
+ if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts:
190
+ raise ValueError("evidence path must be relative and cannot contain '..'")
191
+ return candidate
192
+
193
+ @staticmethod
194
+ def _chunks(content: bytes | bytearray | memoryview | str | Path | BinaryIO):
195
+ if isinstance(content, Path):
196
+ with Path(content).open("rb") as source:
197
+ yield from iter(lambda: source.read(1024 * 1024), b"")
198
+ elif isinstance(content, str):
199
+ yield content.encode("utf-8")
200
+ elif isinstance(content, (bytes, bytearray, memoryview)):
201
+ yield bytes(content)
202
+ else:
203
+ yield from iter(lambda: content.read(1024 * 1024), b"")
204
+
205
+ @staticmethod
206
+ def _media_type(path: Path) -> str:
207
+ return mimetypes.guess_type(path.name)[0] or "application/octet-stream"