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,21 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "document-2.0.json",
4
+ "title": "Modao PRD document",
5
+ "type": "object",
6
+ "required": ["schema_version", "source", "facts", "derived", "evidence", "coverage", "warnings", "stats"],
7
+ "properties": {
8
+ "schema_version": {"const": "2.0"},
9
+ "source": {"type": "object"},
10
+ "facts": {"type": "object", "required": ["screens", "states", "blocks", "tables", "assets", "observed_interactions", "visual_regions"]},
11
+ "derived": {"type": "object", "required": ["requirements", "declared_interactions", "layout_relations"]},
12
+ "evidence": {"type": "array"},
13
+ "coverage": {"type": "object"},
14
+ "capture": {"type": "object"},
15
+ "network": {"type": "array"},
16
+ "stats": {"type": "object"},
17
+ "document": {"type": "object"},
18
+ "warnings": {"type": "array", "items": {"type": "string"}}
19
+ },
20
+ "additionalProperties": true
21
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "document-2.1.json",
4
+ "title": "Modao PRD Agent document",
5
+ "description": "Compact Agent-facing projection. Detailed DOM/layout evidence is referenced from the evidence bundle.",
6
+ "type": "object",
7
+ "required": ["schema_version", "source", "facts", "derived", "evidence", "coverage", "warnings", "stats"],
8
+ "properties": {
9
+ "schema_version": {"const": "2.1"},
10
+ "source": {"type": "object"},
11
+ "capture": {"type": "object"},
12
+ "facts": {
13
+ "type": "object",
14
+ "required": ["screens", "states", "blocks", "tables", "assets", "observed_interactions", "visual_regions"]
15
+ },
16
+ "derived": {
17
+ "type": "object",
18
+ "required": ["requirements", "declared_interactions"]
19
+ },
20
+ "evidence": {"type": "array"},
21
+ "network": {"type": "array"},
22
+ "coverage": {"type": "object"},
23
+ "document": {
24
+ "type": "object",
25
+ "required": ["title", "type", "sections"]
26
+ },
27
+ "warnings": {"type": "array", "items": {"type": "string"}},
28
+ "stats": {"type": "object"}
29
+ },
30
+ "additionalProperties": false
31
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "manifest-1.0.json",
4
+ "title": "Evidence manifest",
5
+ "type": "object",
6
+ "required": ["schema_version", "generated_by", "files"],
7
+ "properties": {
8
+ "schema_version": {"const": "1.0"},
9
+ "generated_by": {"type": "string", "minLength": 1},
10
+ "total_size": {"type": "integer", "minimum": 0},
11
+ "files": {"type": "array", "items": {"$ref": "#/$defs/file"}},
12
+ "metadata": {"type": "object"}
13
+ },
14
+ "$defs": {
15
+ "file": {
16
+ "type": "object",
17
+ "required": ["path", "sha256", "size", "media_type"],
18
+ "properties": {
19
+ "path": {"type": "string", "minLength": 1},
20
+ "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
21
+ "size": {"type": "integer", "minimum": 0},
22
+ "media_type": {"type": "string", "minLength": 1}
23
+ },
24
+ "additionalProperties": false
25
+ }
26
+ },
27
+ "additionalProperties": true
28
+ }
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <main id="screens">
5
+ <section class="mb-screen" data-title="方舟远征">
6
+ <div class="widget tree-node wRichText"><div class="text">方舟纪元</div></div>
7
+ <div class="widget tree-node wRichText"><div class="text">活动时间:7月2日10点-7月8日24点</div></div>
8
+ <div class="widget tree-node wRichText"><div class="text">点击选中奖池,用户可同时选择1-3奖池。选择低于1个时toast:最少选择1个奖池~。</div></div>
9
+ <button>远航1次</button>
10
+ <table>
11
+ <tr><th>礼物名称</th><th>抽1次需要消耗的道具</th><th>获得概率</th></tr>
12
+ <tr><td>礼物名称(100钻)</td><td>1</td><td>10%</td></tr>
13
+ <tr><td>礼物名称(500钻)</td><td>2</td><td>5%</td></tr>
14
+ </table>
15
+ <img src="https://cdn.example.test/gift.png" alt="礼物图" />
16
+ </section>
17
+ </main>
18
+ </body>
19
+ </html>
20
+
@@ -0,0 +1,54 @@
1
+ import pytest
2
+
3
+ from modao_prd_cli.modao_prd.browser import (
4
+ default_output_root,
5
+ ensure_default_output_ignored,
6
+ find_project_root,
7
+ validate_share_url,
8
+ )
9
+ from modao_prd_cli.modao_prd.errors import ModaoPrdError
10
+
11
+
12
+ def test_validate_share_url_extracts_project_id():
13
+ result = validate_share_url("https://modao.cc/proto/k1dsCPMlterfdx9cPa11KY/sharing?view_mode=inspect")
14
+ assert result.project_id == "k1dsCPMlterfdx9cPa11KY"
15
+ assert result.host == "modao.cc"
16
+
17
+
18
+ @pytest.mark.parametrize(
19
+ "url",
20
+ [
21
+ "http://modao.cc/proto/demo/sharing",
22
+ "https://example.com/proto/demo/sharing",
23
+ "https://modao.cc/proto/demo/design",
24
+ "https://user:pass@modao.cc/proto/demo/sharing",
25
+ "https://modao.cc:8443/proto/demo/sharing",
26
+ ],
27
+ )
28
+ def test_validate_share_url_rejects_unsafe_or_unsupported_urls(url):
29
+ with pytest.raises(ModaoPrdError):
30
+ validate_share_url(url)
31
+
32
+
33
+ def test_default_output_uses_nearest_git_project_root(tmp_path):
34
+ project = tmp_path / "project"
35
+ nested = project / "packages" / "web"
36
+ nested.mkdir(parents=True)
37
+ (project / ".git").mkdir()
38
+
39
+ assert find_project_root(nested) == project
40
+ assert default_output_root(nested) == project / ".modao-prd"
41
+
42
+
43
+ def test_default_output_falls_back_to_current_directory(tmp_path):
44
+ assert find_project_root(tmp_path) == tmp_path
45
+ assert default_output_root(tmp_path) == tmp_path / ".modao-prd"
46
+
47
+
48
+ def test_default_output_gitignore_entry_is_idempotent(tmp_path):
49
+ ignore_file = tmp_path / ".gitignore"
50
+ ignore_file.write_text("dist/", encoding="utf-8")
51
+
52
+ assert ensure_default_output_ignored(tmp_path) is True
53
+ assert ensure_default_output_ignored(tmp_path) is False
54
+ assert ignore_file.read_text(encoding="utf-8") == "dist/\n/.modao-prd/\n"
@@ -0,0 +1,32 @@
1
+ from modao_prd_cli.modao_prd.classifier import classify_blocks
2
+
3
+
4
+ def test_classifier_extracts_validation_interaction_and_feedback():
5
+ requirements, interactions, warnings = classify_blocks(
6
+ [
7
+ {
8
+ "id": "block-001",
9
+ "type": "text",
10
+ "text": "点击选中奖池,选择低于1个时toast:最少选择1个奖池~。",
11
+ }
12
+ ]
13
+ )
14
+ assert requirements[0]["type"] == "validation"
15
+ assert requirements[0]["feedback"] == "最少选择1个奖池~"
16
+ assert interactions[0]["trigger"].startswith("点击")
17
+ assert warnings == []
18
+
19
+
20
+ def test_classifier_marks_probability_and_external_reference_for_review():
21
+ requirements, _interactions, warnings = classify_blocks(
22
+ [
23
+ {
24
+ "id": "block-001",
25
+ "type": "text",
26
+ "text": "获得概率10%,具体配置见谷歌文档。",
27
+ }
28
+ ]
29
+ )
30
+ assert requirements[0]["type"] == "probability"
31
+ assert any("概率" in warning for warning in warnings)
32
+ assert any("外部文档" in warning for warning in warnings)
@@ -0,0 +1,88 @@
1
+ import json
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ from click.testing import CliRunner
6
+
7
+ from modao_prd_cli.modao_prd.cli import cli
8
+ from modao_prd_cli.modao_prd.extractor import extract_from_html
9
+
10
+
11
+ def _raw_page():
12
+ return {
13
+ "requested_url": "https://modao.cc/proto/cli_test/sharing",
14
+ "final_url": "https://modao.cc/proto/cli_test/sharing",
15
+ "project_id": "cli_test",
16
+ "title": "CLI 测试页",
17
+ "body_text_length": 120,
18
+ "screens": [{"index": 0, "title": "首页"}],
19
+ "records": [
20
+ {
21
+ "kind": "text",
22
+ "text": "活动时间:7月2日",
23
+ "screen_index": 0,
24
+ "tag": "div",
25
+ "selector": ".wRichText",
26
+ "dom_order": 0,
27
+ }
28
+ ],
29
+ }
30
+
31
+
32
+ def test_help_and_doctor_json():
33
+ runner = CliRunner()
34
+ help_result = runner.invoke(cli, ["--help"])
35
+ assert help_result.exit_code == 0
36
+ assert "inspect" in help_result.output
37
+ with patch("modao_prd_cli.modao_prd.cli.doctor", return_value={"ready": True}):
38
+ result = runner.invoke(cli, ["doctor", "--json"])
39
+ assert result.exit_code == 0
40
+ assert json.loads(result.output)["ready"] is True
41
+
42
+
43
+ def test_inspect_json_uses_capture_backend():
44
+ runner = CliRunner()
45
+ with patch("modao_prd_cli.modao_prd.cli.capture_page", return_value=_raw_page()):
46
+ result = runner.invoke(cli, ["inspect", _raw_page()["requested_url"], "--json"])
47
+ assert result.exit_code == 0
48
+ payload = json.loads(result.output)
49
+ assert payload["title"] == "CLI 测试页"
50
+ assert payload["screens"] == 1
51
+
52
+
53
+ def test_export_json_writes_project_directory(tmp_path):
54
+ runner = CliRunner()
55
+ with patch("modao_prd_cli.modao_prd.cli.capture_page", return_value=_raw_page()):
56
+ result = runner.invoke(
57
+ cli,
58
+ ["export", _raw_page()["requested_url"], "--format", "json", "--output", str(tmp_path), "--json"],
59
+ )
60
+ assert result.exit_code == 0, result.output
61
+ payload = json.loads(result.output)
62
+ output_file = tmp_path / "cli_test" / "document.json"
63
+ assert output_file.is_file()
64
+ assert payload["files"]["json"] == str(output_file)
65
+
66
+
67
+ def test_export_defaults_to_hidden_project_directory_and_updates_gitignore(tmp_path, monkeypatch):
68
+ runner = CliRunner()
69
+ monkeypatch.chdir(tmp_path)
70
+ with patch("modao_prd_cli.modao_prd.cli.capture_page", return_value=_raw_page()):
71
+ result = runner.invoke(
72
+ cli,
73
+ ["export", _raw_page()["requested_url"], "--format", "json", "--json"],
74
+ )
75
+
76
+ assert result.exit_code == 0, result.output
77
+ payload = json.loads(result.output)
78
+ output_file = (Path.cwd() / ".modao-prd" / "cli_test" / "document.json").resolve()
79
+ assert output_file.is_file()
80
+ assert payload["files"]["json"] == str(output_file)
81
+ assert Path(".gitignore").read_text(encoding="utf-8") == "/.modao-prd/\n"
82
+
83
+
84
+ def test_invalid_url_is_json_error():
85
+ runner = CliRunner()
86
+ result = runner.invoke(cli, ["inspect", "https://example.com/proto/a/sharing", "--json"])
87
+ assert result.exit_code == 1
88
+ assert json.loads(result.output)["error"]["type"] == "unsupported_url"
@@ -0,0 +1,57 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from modao_prd_cli.modao_prd.extractor import build_document, extract_from_html
5
+
6
+
7
+ FIXTURE = Path(__file__).parent / "fixtures" / "modao_sample.html"
8
+
9
+
10
+ def test_fixture_extracts_blocks_tables_and_assets():
11
+ document = extract_from_html(
12
+ FIXTURE.read_text(encoding="utf-8"),
13
+ title="方舟纪元",
14
+ url="https://modao.cc/proto/demo_123/sharing?view_mode=inspect",
15
+ )
16
+
17
+ assert document["schema_version"] == "2.1"
18
+ assert document["facts"]["blocks"]
19
+ assert document["derived"]["requirements"]
20
+ assert "layout_relations" not in document["derived"]
21
+ assert "blocks" not in document["document"]
22
+ assert "requirements" not in document
23
+ assert document["source"]["project_id"] == "demo_123"
24
+ assert document["facts"]["screens"][0]["title"] == "方舟远征"
25
+ assert any(block["text"] == "方舟纪元" for block in document["facts"]["blocks"])
26
+ assert len(document["facts"]["tables"]) == 1
27
+ assert document["facts"]["tables"][0]["rows"][0]["获得概率"] == "10%"
28
+ assert document["facts"]["assets"][0]["src"].endswith("gift.png")
29
+
30
+
31
+ def test_agent_document_does_not_repeat_state_evidence_on_every_node():
32
+ raw = {
33
+ "title": "大页面",
34
+ "requested_url": "https://modao.cc/proto/demo/sharing",
35
+ "final_url": "https://modao.cc/proto/demo/sharing",
36
+ "project_id": "demo",
37
+ "screens": [{"index": 0, "title": "首页"}],
38
+ "records": [
39
+ {"kind": "text", "text": "说明", "screen_index": 0, "dom_order": 0},
40
+ {"kind": "button", "text": "继续", "screen_index": 0, "dom_order": 1},
41
+ ],
42
+ "evidence": [
43
+ {"id": "evidence-001", "kind": "asset", "state_id": "state-001", "path": "evidence/assets/a.png"},
44
+ {"id": "evidence-002", "kind": "dom", "state_id": "state-001", "path": "evidence/state-001.dom.json"},
45
+ {"id": "evidence-003", "kind": "screen_screenshot", "state_id": "state-001", "path": "evidence/state-001.screen-001.png"},
46
+ ],
47
+ }
48
+ document = build_document(raw)
49
+ assert document["facts"]["blocks"][0]["evidence_ids"] == ["evidence-002", "evidence-003"]
50
+ assert document["facts"]["blocks"][1]["evidence_ids"] == ["evidence-002", "evidence-003"]
51
+ assert document["facts"]["screens"][0]["evidence_ids"] == ["evidence-002", "evidence-003"]
52
+ assert len(json.dumps(document, ensure_ascii=False)) < 12000
53
+
54
+
55
+ def test_ndjson_like_payload_is_json_safe():
56
+ document = extract_from_html(FIXTURE.read_text(encoding="utf-8"))
57
+ assert json.dumps(document, ensure_ascii=False)
@@ -0,0 +1,23 @@
1
+ import json
2
+ import os
3
+
4
+ import pytest
5
+
6
+ from modao_prd_cli.modao_prd.browser import capture_page
7
+ from modao_prd_cli.modao_prd.extractor import build_document
8
+
9
+
10
+ SAMPLE_URL = "https://modao.cc/proto/k1dsCPMlterfdx9cPa11KY/sharing?view_mode=inspect"
11
+
12
+
13
+ @pytest.mark.live
14
+ @pytest.mark.skipif(os.environ.get("MODAO_PRD_LIVE_E2E") != "1", reason="set MODAO_PRD_LIVE_E2E=1 to access Modao")
15
+ def test_live_modao_share_is_extractable():
16
+ document = build_document(capture_page(SAMPLE_URL, timeout_seconds=45))
17
+ assert document["source"]["title"] == "7月活动-方舟纪元"
18
+ assert document["stats"]["body_text_length"] > 1000
19
+ assert document["stats"]["text_block_count"] > 0
20
+ assert document["stats"]["table_count"] >= 1
21
+ assert document["stats"]["interaction_count"] > 0
22
+ assert document["stats"]["requirement_count"] > 0
23
+ assert json.dumps(document, ensure_ascii=False)
@@ -0,0 +1,79 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from modao_prd_cli.modao_prd.extractor import extract_from_html
6
+ from modao_prd_cli.modao_prd.writers import render_markdown, render_ndjson, write_outputs
7
+
8
+
9
+ def _document():
10
+ return extract_from_html(
11
+ '<section class="mb-screen" data-title="页面一"><div class="widget tree-node wRichText">点击按钮</div></section>',
12
+ title="测试文档",
13
+ url="https://modao.cc/proto/writer_test/sharing",
14
+ )
15
+
16
+
17
+ def test_renderers_produce_expected_formats():
18
+ document = _document()
19
+ markdown = render_markdown(document)
20
+ ndjson = render_ndjson(document)
21
+ assert "# 测试文档" in markdown
22
+ assert "## 抓取摘要" in markdown
23
+ assert "#### 交互说明" in markdown
24
+ assert markdown.count("点击按钮") == 1
25
+ assert "## 独立规则" not in markdown
26
+ assert all(json.loads(line)["type"] for line in ndjson.splitlines())
27
+
28
+
29
+ def test_markdown_gives_tables_a_contextual_title():
30
+ document = extract_from_html(
31
+ """
32
+ <section class="mb-screen" data-title="页面一">
33
+ <table>
34
+ <tr><th>礼物</th><th>概率</th></tr>
35
+ <tr><td>礼物 A</td><td>10%</td></tr>
36
+ </table>
37
+ </section>
38
+ """,
39
+ title="测试文档",
40
+ url="https://modao.cc/proto/writer_table/sharing",
41
+ )
42
+ markdown = render_markdown(document)
43
+ assert "#### 表格" in markdown
44
+ assert "##### 页面一 - 表格 1" in markdown
45
+ assert "#### 未命名表格" not in markdown
46
+
47
+
48
+ def test_write_outputs_refuses_existing_files_without_force(tmp_path):
49
+ document = _document()
50
+ first = write_outputs(document, tmp_path, "all")
51
+ assert set(first["files"]) == {"json", "markdown", "ndjson"}
52
+ with pytest.raises(FileExistsError):
53
+ write_outputs(document, tmp_path, "all")
54
+ second = write_outputs(document, tmp_path, "all", force=True)
55
+ assert second["overwrote"] is True
56
+
57
+
58
+ def test_write_outputs_with_evidence_preserves_output_and_manifest_contract(tmp_path):
59
+ document = _document()
60
+ output_root = tmp_path / "output"
61
+ output_dir = output_root / "writer_test"
62
+ from modao_prd_cli.modao_prd.evidence import EvidenceBundleWriter
63
+
64
+ output_dir.mkdir(parents=True)
65
+ writer = EvidenceBundleWriter(output_dir)
66
+ result = write_outputs(document, output_root, "all", evidence_writer=writer)
67
+
68
+ assert set(result["files"]) == {"json", "markdown", "ndjson"}
69
+ assert result["format"] == "all"
70
+ assert result["manifest"] == str(output_dir / "manifest.json")
71
+ assert result["coverage"] == str(output_dir / "coverage.json")
72
+
73
+ manifest = json.loads((output_dir / "manifest.json").read_text())
74
+ paths = {item["path"] for item in manifest["files"]}
75
+ assert {"document.json", "document.md", "document.ndjson", "coverage.json"} <= paths
76
+ assert "evidence/network/index.ndjson" not in paths
77
+ assert manifest["metadata"]["project_id"] == "writer_test"
78
+ assert manifest["metadata"]["document"] == "document.json"
79
+ assert manifest["metadata"]["format"] == "all"