openmd-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 (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,64 @@
1
+ """Windows-safe title filename helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ INVALID_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
11
+ WHITESPACE_RE = re.compile(r"\s+")
12
+
13
+
14
+ def short_hash(value: str | None, *, length: int = 8) -> str:
15
+ seed = value or "openmd"
16
+ return hashlib.sha1(seed.encode("utf-8", errors="ignore")).hexdigest()[:length]
17
+
18
+
19
+ def safe_filename(value: str | None, *, fallback: str = "untitled", max_length: int = 120) -> str:
20
+ text = value or fallback
21
+ text = INVALID_FILENAME_CHARS_RE.sub(" ", text)
22
+ text = WHITESPACE_RE.sub(" ", text).strip(" .")
23
+ if not text:
24
+ text = fallback
25
+ if len(text) > max_length:
26
+ text = text[:max_length].rstrip(" .")
27
+ return text or "untitled"
28
+
29
+
30
+ def filename_base_for_item(
31
+ title: str | None,
32
+ *,
33
+ item_id: str | None = None,
34
+ url: str | None = None,
35
+ max_length: int = 120,
36
+ ) -> str:
37
+ fallback = item_id or short_hash(url)
38
+ return safe_filename(title, fallback=fallback, max_length=max_length)
39
+
40
+
41
+ def unique_markdown_path(
42
+ directory: Path,
43
+ filename_base: str,
44
+ *,
45
+ collision_key: str | None = None,
46
+ overwrite: bool = False,
47
+ ) -> Path:
48
+ directory.mkdir(parents=True, exist_ok=True)
49
+ base = safe_filename(filename_base)
50
+ candidate = directory / f"{base}.md"
51
+ if overwrite or not candidate.exists():
52
+ return candidate
53
+
54
+ suffix = short_hash(collision_key or base)
55
+ candidate = directory / f"{base}-{suffix}.md"
56
+ if not candidate.exists():
57
+ return candidate
58
+
59
+ counter = 2
60
+ while True:
61
+ candidate = directory / f"{base}-{suffix}-{counter}.md"
62
+ if not candidate.exists():
63
+ return candidate
64
+ counter += 1
@@ -0,0 +1,91 @@
1
+ """Daily-input classification and bounded, best-effort public URL preflight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import queue
6
+ import socket
7
+ import threading
8
+ from urllib.parse import urlparse
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ from .detector import _local_path_for_detection
13
+
14
+
15
+ INPUT_HINT = "请粘贴 http(s) 链接或本地文件路径"
16
+
17
+
18
+ def valid_daily_input(value: str) -> bool:
19
+ try:
20
+ parsed = urlparse(value)
21
+ if parsed.scheme in {"http", "https"}:
22
+ return bool(parsed.hostname) and not any(ch.isspace() for ch in value)
23
+ path = _local_path_for_detection(value)
24
+ return path is not None and path.is_file()
25
+ except (OSError, ValueError):
26
+ return False
27
+
28
+
29
+ def _probe_url(url: str) -> str | None:
30
+ parsed = urlparse(url)
31
+ host = (parsed.hostname or "").rstrip(".").lower()
32
+ # RFC-reserved invalid names stay invalid even under a fake-DNS proxy.
33
+ if host == "invalid" or host.endswith(".invalid"):
34
+ return "域名无法访问"
35
+ proxies = urllib.request.getproxies()
36
+ # A configured proxy may resolve the origin remotely. Local DNS must not
37
+ # veto that route or consume its short preflight budget.
38
+ if not (proxies.get(parsed.scheme) or proxies.get("all")):
39
+ try:
40
+ socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)
41
+ except socket.gaierror as exc:
42
+ if exc.errno in {socket.EAI_NONAME, getattr(socket, "EAI_NODATA", socket.EAI_NONAME)}:
43
+ return "域名无法访问"
44
+ return None
45
+ except (OSError, ValueError):
46
+ return None
47
+ # urllib supplies HTTP(S)_PROXY/NO_PROXY handling, but ALL_PROXY needs an
48
+ # explicit scheme fallback. Do not persist proxy URLs or credentials.
49
+ if proxies.get("all"):
50
+ proxies.setdefault("http", proxies["all"])
51
+ proxies.setdefault("https", proxies["all"])
52
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler(proxies))
53
+ # A proxied TLS round trip regularly takes 2-4s here, so a 2s HEAD followed
54
+ # by a 2s GET timed out before either answered. Spend the budget on one
55
+ # HEAD; fall back to GET only when the server answered but rejected HEAD.
56
+ for method, timeout in (("HEAD", 4.0), ("GET", 2.0)):
57
+ try:
58
+ request = urllib.request.Request(url, method=method, headers={"User-Agent": "Openmd/0.0.1"})
59
+ with opener.open(request, timeout=timeout):
60
+ return None
61
+ except urllib.error.HTTPError as exc:
62
+ code = exc.code
63
+ exc.close()
64
+ if code in {400, 404, 410, 414, 422}:
65
+ label = "页面不存在" if code in {404, 410} else "页面无法访问"
66
+ return f"{label}({code})"
67
+ except (OSError, ValueError):
68
+ return None
69
+ return None
70
+
71
+
72
+ def network_preflight(url: str, *, timeout: float = 4.8) -> str | None:
73
+ """Stop only on a positive DNS/not-found signal, never on uncertainty.
74
+
75
+ DNS resolution itself has no portable socket timeout. Keep its worker a
76
+ daemon and bound the entire wait so a slow resolver cannot stall the CLI.
77
+ No response body or credentials are read or persisted.
78
+ """
79
+ results: queue.Queue[str | None] = queue.Queue(maxsize=1)
80
+
81
+ def probe() -> None:
82
+ try:
83
+ results.put(_probe_url(url))
84
+ except Exception:
85
+ results.put(None)
86
+
87
+ threading.Thread(target=probe, name="openmd-url-preflight", daemon=True).start()
88
+ try:
89
+ return results.get(timeout=timeout)
90
+ except queue.Empty:
91
+ return None
@@ -0,0 +1,96 @@
1
+ """Machine-local tool locations shared by the controller and isolated workers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ SCHEMA = "openmd.local/v1"
13
+ DEFAULT_QWEN3_BATCH_SIZE = 4 # user decision 2026-11-09: ~2.1x faster; not byte-identical to batch 1
14
+ MAX_QWEN3_BATCH_SIZE = 32
15
+ _PRIVATE_KEY = re.compile(r"cookie|token|auth|session|password|secret|credential|api.?key", re.I)
16
+
17
+
18
+ def checkout_root() -> Path | None:
19
+ root = Path(__file__).resolve().parents[3]
20
+ return root if (root / "pyproject.toml").is_file() and (root / "src/openmd").is_dir() else None
21
+
22
+
23
+ def local_config_path() -> Path | None:
24
+ explicit = os.environ.get("OPENMD_CONFIG")
25
+ if explicit:
26
+ return Path(explicit).expanduser().resolve()
27
+ root = checkout_root()
28
+ if root is not None and (root / "out/local-config.json").is_file():
29
+ return root / "out/local-config.json"
30
+ default = Path.home() / ".openmd/local.json"
31
+ return default if default.is_file() else None
32
+
33
+
34
+ def _check_keys(value: Any) -> None:
35
+ if isinstance(value, dict):
36
+ for key, child in value.items():
37
+ if _PRIVATE_KEY.search(key):
38
+ raise ValueError("local_config_private_field: credentials do not belong in Openmd location configuration")
39
+ _check_keys(child)
40
+ elif isinstance(value, list):
41
+ for child in value:
42
+ _check_keys(child)
43
+
44
+
45
+ def load_local_config() -> dict[str, Any]:
46
+ """Return optional location metadata; explicit malformed configuration fails closed."""
47
+ path = local_config_path()
48
+ if path is None:
49
+ return {}
50
+ if not path.is_file():
51
+ raise ValueError("local_config_missing: OPENMD_CONFIG must name an existing local JSON file")
52
+ if path.stat().st_size > 128 * 1024:
53
+ raise ValueError("local_config_too_large")
54
+ try:
55
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
56
+ except (OSError, ValueError) as exc:
57
+ raise ValueError("local_config_invalid: expected local position configuration JSON") from exc
58
+ if not isinstance(value, dict) or value.get("schema") != SCHEMA:
59
+ raise ValueError("local_config_schema: expected openmd.local/v1")
60
+ _check_keys(value)
61
+ if set(value) - {"schema", "asr_python", "qwen3_batch_size", "ocr_python", "ollama_url", "ocr", "soffice", "model_roots", "tools", "route_overlay"}:
62
+ raise ValueError("local_config_unknown_field")
63
+ if "qwen3_batch_size" in value:
64
+ if type(value["qwen3_batch_size"]) is not int:
65
+ raise ValueError("local_config_qwen3_batch_size_invalid: expected an integer")
66
+ validate_qwen3_batch_size(value["qwen3_batch_size"])
67
+ if "asr_python" in value and not isinstance(value["asr_python"], str):
68
+ raise ValueError("local_config_asr_python_invalid")
69
+ for key in ("ocr_python", "ollama_url", "soffice"):
70
+ if key in value and not isinstance(value[key], str):
71
+ raise ValueError(f"local_config_{key}_invalid")
72
+ if "ocr" in value and not isinstance(value["ocr"], dict):
73
+ raise ValueError("local_config_ocr_invalid")
74
+ if "model_roots" in value and (not isinstance(value["model_roots"], list) or not all(isinstance(p, str) for p in value["model_roots"])):
75
+ raise ValueError("local_config_model_roots_invalid")
76
+ if "tools" in value and not isinstance(value["tools"], dict):
77
+ raise ValueError("local_config_tools_invalid")
78
+ if "route_overlay" in value and not isinstance(value["route_overlay"], str):
79
+ raise ValueError("local_config_route_overlay_invalid")
80
+ return value
81
+
82
+
83
+ def validate_qwen3_batch_size(value: object) -> int:
84
+ """Validate a bounded batch size without importing the GPU stack."""
85
+ if isinstance(value, str) and re.fullmatch(r"[0-9]+", value.strip()):
86
+ value = int(value.strip())
87
+ if type(value) is not int or not 1 <= value <= MAX_QWEN3_BATCH_SIZE:
88
+ raise ValueError(f"qwen3_batch_size_invalid: expected an integer from 1 to {MAX_QWEN3_BATCH_SIZE}")
89
+ return value
90
+
91
+
92
+ def qwen3_batch_size() -> int:
93
+ """Environment overrides local JSON; scalar inference is the safe default."""
94
+ local = load_local_config()
95
+ value = os.environ.get("OPENMD_QWEN3_BATCH_SIZE", local.get("qwen3_batch_size", DEFAULT_QWEN3_BATCH_SIZE))
96
+ return validate_qwen3_batch_size(value)
@@ -0,0 +1,156 @@
1
+ """Discovery manifest writer for collection workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from .filename import safe_filename, short_hash
11
+ from .models import ContentItem
12
+ from .secrets import sanitize_for_logging, sanitize_url
13
+
14
+
15
+ def default_manifest_path(
16
+ out_dir: str | Path,
17
+ *,
18
+ platform: str,
19
+ collection_url: str,
20
+ ) -> Path:
21
+ """Return a stable per-collection JSONL manifest path."""
22
+ slug = _collection_slug(collection_url) or short_hash(collection_url)
23
+ filename = safe_filename(
24
+ f"{platform}_{slug}-{short_hash(collection_url)}",
25
+ fallback=f"{platform}_{short_hash(collection_url)}",
26
+ max_length=120,
27
+ )
28
+ return Path(out_dir) / "manifests" / f"{filename}.jsonl"
29
+
30
+
31
+ def write_discovery_manifest(
32
+ *,
33
+ out_dir: str | Path,
34
+ platform: str,
35
+ collection_url: str,
36
+ provider: str,
37
+ items: list[ContentItem],
38
+ limit: int | None = None,
39
+ ) -> Path:
40
+ """Append newly discovered items to a per-collection manifest.
41
+
42
+ The manifest uses generic ``content_id``/``content_url`` fields while
43
+ retaining the legacy video aliases. Existing keys are not appended again,
44
+ so repeated smoke runs stay resumable without growing duplicate rows.
45
+ """
46
+ manifest_path = default_manifest_path(
47
+ out_dir,
48
+ platform=platform,
49
+ collection_url=collection_url,
50
+ )
51
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
52
+
53
+ existing_keys = _read_existing_manifest_keys(manifest_path)
54
+ with manifest_path.open("a", encoding="utf-8", newline="\n") as file:
55
+ for item in items:
56
+ row = discovery_manifest_row(
57
+ item,
58
+ collection_url=collection_url,
59
+ provider=provider,
60
+ limit=limit,
61
+ )
62
+ key = _manifest_key(row)
63
+ if key in existing_keys:
64
+ continue
65
+ existing_keys.add(key)
66
+ safe_row = sanitize_for_logging(row)
67
+ file.write(json.dumps(safe_row, ensure_ascii=False, sort_keys=True) + "\n")
68
+ return manifest_path
69
+
70
+
71
+ def discovery_manifest_row(
72
+ item: ContentItem,
73
+ *,
74
+ collection_url: str,
75
+ provider: str,
76
+ limit: int | None = None,
77
+ ) -> dict[str, Any]:
78
+ """Build one normalized discovery manifest row."""
79
+ raw_meta = item.raw_meta
80
+ return {
81
+ "platform": item.platform,
82
+ "collection_url": sanitize_url(collection_url),
83
+ "playlist_id": raw_meta.get("playlist_id"),
84
+ "channel_id": raw_meta.get("channel_id"),
85
+ "channel_title": raw_meta.get("channel_title") or item.author,
86
+ "content_id": item.id,
87
+ "content_url": sanitize_url(item.url),
88
+ "video_id": item.id,
89
+ "video_url": sanitize_url(item.url),
90
+ "resource_id": raw_meta.get("resource_id") or item.id,
91
+ "resource_type": raw_meta.get("resource_type"),
92
+ "resource_label": raw_meta.get("resource_label"),
93
+ "resource_kind": raw_meta.get("resource_kind"),
94
+ "container": raw_meta.get("container", False),
95
+ "canonical_url_available": raw_meta.get(
96
+ "canonical_url_available", bool(item.url)
97
+ ),
98
+ "title": item.title,
99
+ "author": item.author,
100
+ "published_at": item.published_at,
101
+ "published_hint": raw_meta.get("published_hint"),
102
+ "duration": raw_meta.get("duration"),
103
+ "view_count": raw_meta.get("view_count") or raw_meta.get("views"),
104
+ "caption_status": raw_meta.get("caption_status") or "unknown",
105
+ "process_status": raw_meta.get("process_status") or "discovered",
106
+ "discovery_only": True,
107
+ "source_type": item.source_type,
108
+ "media_kind": raw_meta.get("media_kind"),
109
+ "aweme_type": raw_meta.get("aweme_type"),
110
+ "image_count": raw_meta.get("image_count"),
111
+ "discovery_provider": provider,
112
+ "discovery_limit": limit,
113
+ "source_exhausted": raw_meta.get("source_exhausted"),
114
+ "page_count": raw_meta.get("page_count"),
115
+ "empty_page_count": raw_meta.get("empty_page_count"),
116
+ "duplicate_page_count": raw_meta.get("duplicate_page_count"),
117
+ "pagination_retry_count": raw_meta.get("pagination_retry_count"),
118
+ }
119
+
120
+
121
+ def _collection_slug(collection_url: str) -> str | None:
122
+ parsed = urlparse(collection_url if "://" in collection_url else f"https://{collection_url}")
123
+ path_parts = [part for part in parsed.path.split("/") if part]
124
+ if path_parts:
125
+ if path_parts[0].startswith("@"):
126
+ return path_parts[0].lstrip("@")
127
+ if len(path_parts) >= 2 and path_parts[0] in {"channel", "c", "user"}:
128
+ return path_parts[1]
129
+ return parsed.netloc or None
130
+
131
+
132
+ def _read_existing_manifest_keys(path: Path) -> set[str]:
133
+ if not path.exists():
134
+ return set()
135
+ keys: set[str] = set()
136
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
137
+ if not line.strip():
138
+ continue
139
+ try:
140
+ row = json.loads(line)
141
+ except json.JSONDecodeError:
142
+ continue
143
+ if isinstance(row, dict):
144
+ keys.add(_manifest_key(row))
145
+ return keys
146
+
147
+
148
+ def _manifest_key(row: dict[str, Any]) -> str:
149
+ return str(
150
+ row.get("content_id")
151
+ or row.get("video_id")
152
+ or row.get("content_url")
153
+ or row.get("video_url")
154
+ or row.get("title")
155
+ or ""
156
+ )
openmd/core/models.py ADDED
@@ -0,0 +1,168 @@
1
+ """Core dataclasses shared by Openmd adapters and renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+
9
+ InputType = Literal["single", "batch", "collection", "generic"]
10
+ SourceType = Literal[
11
+ "article",
12
+ "video",
13
+ "audio",
14
+ "image_text",
15
+ "mixed",
16
+ "document",
17
+ "conversation",
18
+ "process",
19
+ ]
20
+ Status = Literal["verified", "partial", "failed"]
21
+ ComponentStatus = Literal[
22
+ "verified",
23
+ "partial",
24
+ "failed",
25
+ "retryable",
26
+ "unavailable",
27
+ "not_applicable",
28
+ "auth_challenge",
29
+ ]
30
+ FailureClass = Literal[
31
+ "user_resolvable",
32
+ "code_resolvable",
33
+ "source_unavailable",
34
+ "runtime_transient",
35
+ ]
36
+ SourceLevel = Literal[
37
+ "native_text",
38
+ "native_caption",
39
+ "auto_caption",
40
+ "platform_transcript",
41
+ "third_party_extractor",
42
+ "browser_rendered",
43
+ "manual_export",
44
+ "ocr",
45
+ "asr",
46
+ "media_asr",
47
+ "live_capture_asr",
48
+ "partial_extraction",
49
+ ]
50
+
51
+ STATUS_VALUES: tuple[str, ...] = ("verified", "partial", "failed")
52
+ FAILURE_CLASS_VALUES: tuple[str, ...] = (
53
+ "user_resolvable",
54
+ "code_resolvable",
55
+ "source_unavailable",
56
+ "runtime_transient",
57
+ )
58
+ SOURCE_LEVEL_VALUES: tuple[str, ...] = (
59
+ "native_text",
60
+ "native_caption",
61
+ "auto_caption",
62
+ "platform_transcript",
63
+ "third_party_extractor",
64
+ "browser_rendered",
65
+ "manual_export",
66
+ "ocr",
67
+ "asr",
68
+ "media_asr",
69
+ "live_capture_asr",
70
+ "partial_extraction",
71
+ )
72
+ COMPONENT_STATUS_VALUES: tuple[str, ...] = (
73
+ "verified",
74
+ "partial",
75
+ "failed",
76
+ "retryable",
77
+ "unavailable",
78
+ "not_applicable",
79
+ "auth_challenge",
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ExtractionComponent:
85
+ """One independently verifiable part of a composite extraction."""
86
+
87
+ id: str
88
+ kind: str
89
+ status: ComponentStatus
90
+ source_level: SourceLevel
91
+ backend: str
92
+ text_ref: str = "index.md#content"
93
+ warnings: list[str] = field(default_factory=list)
94
+ terminal_reason: str | None = None
95
+ error_code: str | None = None
96
+ failure_class: FailureClass | None = None
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ContentItem:
101
+ platform: str
102
+ input_type: InputType
103
+ url: str
104
+ id: str | None = None
105
+ title: str | None = None
106
+ author: str | None = None
107
+ published_at: str | None = None
108
+ source_type: SourceType = "article"
109
+ raw_meta: dict[str, Any] = field(default_factory=dict)
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class TranscriptResult:
114
+ """V0 normalized extraction result.
115
+
116
+ The historical name is kept for CLI stability, but in the backend framework
117
+ this represents extracted text for video, article, audio, and document
118
+ sources. Future versions can promote the alias below to a dedicated
119
+ ExtractionResult model without changing the renderer contract.
120
+ """
121
+
122
+ text: str
123
+ status: Status
124
+ source_level: SourceLevel
125
+ backend: str
126
+ source_notes: list[str] = field(default_factory=list)
127
+ error_code: str | None = None
128
+ error_summary: str | None = None
129
+ title: str | None = None
130
+ author: str | None = None
131
+ published_at: str | None = None
132
+ metadata: dict[str, Any] = field(default_factory=dict)
133
+ extraction_contract_version: str | None = None
134
+ components: list[ExtractionComponent] = field(default_factory=list)
135
+
136
+ @property
137
+ def has_usable_text(self) -> bool:
138
+ return self.status in {"verified", "partial"} and bool(self.text.strip())
139
+
140
+ @property
141
+ def normalized_components(self) -> list[ExtractionComponent]:
142
+ if self.components:
143
+ return list(self.components)
144
+ return [
145
+ ExtractionComponent(
146
+ id="content",
147
+ kind="text",
148
+ status=self.status,
149
+ source_level=self.source_level,
150
+ backend=self.backend,
151
+ warnings=list(self.source_notes),
152
+ )
153
+ ]
154
+
155
+ @property
156
+ def has_terminal_gaps(self) -> bool:
157
+ return any(component.status == "unavailable" for component in self.normalized_components)
158
+
159
+
160
+ ExtractionResult = TranscriptResult
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class MarkdownDocument:
165
+ frontmatter: dict[str, Any]
166
+ title: str
167
+ content: str
168
+ source_notes: list[str] = field(default_factory=list)
@@ -0,0 +1,34 @@
1
+ """Single boundary for OpenCLI's machine-readable stdout.
2
+
3
+ OpenCLI 2.0 wraps JSON output as ``{"ok": true, "data": ...}`` and failures as
4
+ ``{"ok": false, "error": {...}}``. Provider parsers should only ever see the
5
+ inner rows, so the envelope is removed here and nowhere else. Older bare JSON
6
+ passes through unchanged, which keeps OpenCLI 1.x output and fixtures readable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ _ENVELOPE_KEYS = frozenset({"ok", "data", "error"})
15
+
16
+
17
+ def is_opencli_envelope(payload: Any) -> bool:
18
+ return (
19
+ isinstance(payload, dict)
20
+ and isinstance(payload.get("ok"), bool)
21
+ and _ENVELOPE_KEYS.issuperset(payload)
22
+ )
23
+
24
+
25
+ def unwrap_opencli_envelope(payload: Any) -> Any:
26
+ """Return successful envelope data; failures stay intact for error handling."""
27
+ if is_opencli_envelope(payload) and payload["ok"] and "data" in payload:
28
+ return payload["data"]
29
+ return payload
30
+
31
+
32
+ def load_opencli_json(stdout: str) -> Any:
33
+ """``json.loads`` for OpenCLI stdout; raises ``JSONDecodeError`` the same way."""
34
+ return unwrap_opencli_envelope(json.loads(stdout))
@@ -0,0 +1,60 @@
1
+ """Static collection permissions shared by planning and CLI execution.
2
+
3
+ This module must not construct providers or inspect credentials/session state.
4
+ An approved product goal is not permission to run an unsupported operation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from openmd.adapters.wechat_group.wx_cli import OPENCLI_WX_HISTORY_DISCOVERY_BACKEND
10
+ from openmd.adapters.wechat_moments.wx_cli import OPENCLI_WX_SNS_FEED_DISCOVERY_BACKEND
11
+ from openmd.adapters.weixin.discovery import WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND
12
+ from openmd.backends.bilibili import BILIBILI_NATIVE_DISCOVERY_BACKEND
13
+ from openmd.backends.douyin import DOUYIN_OPENCLI_USER_VIDEOS_BACKEND, DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND
14
+ from openmd.backends.xiaoe import OPENCLI_XIAOE_CATALOG_DISCOVERY_BACKEND
15
+ from openmd.backends.xiaohongshu import (
16
+ OPENCLI_OPENMD_XIAOHONGSHU_CREATOR_NOTES_BACKEND,
17
+ OPENCLI_XIAOHONGSHU_USER_DISCOVERY_BACKEND,
18
+ )
19
+ from openmd.backends.xiaoyuzhou import OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND
20
+ from openmd.backends.youtube_discovery import (
21
+ YOUTUBE_DATA_API_DISCOVERY_BACKEND,
22
+ YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
23
+ )
24
+
25
+
26
+ SCOPED_ALL_PROVIDERS: dict[str, frozenset[str | None]] = {
27
+ "weixin": frozenset({None, WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND}),
28
+ "douyin": frozenset({None, DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND}),
29
+ "xiaohongshu": frozenset({None, OPENCLI_OPENMD_XIAOHONGSHU_CREATOR_NOTES_BACKEND}),
30
+ }
31
+ _YOUTUBE_ALL_PROVIDERS = frozenset(
32
+ {None, YOUTUBE_DATA_API_DISCOVERY_BACKEND, YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND}
33
+ )
34
+
35
+
36
+ def collect_all_allowed(
37
+ platform: str,
38
+ *,
39
+ browser_enabled: bool,
40
+ network_enabled: bool = False,
41
+ discovery_provider: str | None = None,
42
+ ) -> bool:
43
+ """Match current scoped CLI support; callers normalize provider aliases."""
44
+ if platform == "youtube":
45
+ return network_enabled and discovery_provider in _YOUTUBE_ALL_PROVIDERS
46
+ return browser_enabled and discovery_provider in SCOPED_ALL_PROVIDERS.get(platform, frozenset())
47
+
48
+
49
+ # These are discovery IDs, deliberately separate from extraction bindings.
50
+ COLLECTION_BINDINGS: dict[str, tuple[str, tuple[str, ...]]] = {
51
+ "bilibili": (BILIBILI_NATIVE_DISCOVERY_BACKEND, ("network",)),
52
+ "youtube": (YOUTUBE_DATA_API_DISCOVERY_BACKEND, ("network", "account")),
53
+ "weixin": (WECHAT_ARTICLE_EXPORTER_HTTP_DISCOVERY_BACKEND, ("browser",)),
54
+ "xiaoyuzhou": (OPENCLI_XIAOYUZHOU_EPISODES_DISCOVERY_BACKEND, ("browser",)),
55
+ "douyin": (DOUYIN_OPENCLI_USER_VIDEOS_BACKEND, ("browser",)),
56
+ "xiaoe": (OPENCLI_XIAOE_CATALOG_DISCOVERY_BACKEND, ("browser",)),
57
+ "xiaohongshu_creator": (OPENCLI_XIAOHONGSHU_USER_DISCOVERY_BACKEND, ("browser",)),
58
+ "wechat_group": (OPENCLI_WX_HISTORY_DISCOVERY_BACKEND, ("browser",)),
59
+ "wechat_moments": (OPENCLI_WX_SNS_FEED_DISCOVERY_BACKEND, ("browser",)),
60
+ }