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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
openmd/core/filename.py
ADDED
|
@@ -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)
|
openmd/core/manifest.py
ADDED
|
@@ -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
|
+
}
|