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/output.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Markdown artifact output writer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
from urllib.parse import parse_qs, urlparse
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
from .filename import filename_base_for_item, safe_filename, unique_markdown_path
|
|
13
|
+
from .models import ContentItem, MarkdownDocument, TranscriptResult
|
|
14
|
+
from .renderer import render_document, render_markdown_document
|
|
15
|
+
from .secrets import sanitize_error_summary, sanitize_url_text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
AssetFetcher = Callable[[str, dict[str, str], int], tuple[bytes, str | None]]
|
|
19
|
+
|
|
20
|
+
_MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^\s)]+)\)")
|
|
21
|
+
_SAFE_EXTENSION_RE = re.compile(r"^[A-Za-z0-9]{2,5}$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_markdown_document(
|
|
25
|
+
document: MarkdownDocument,
|
|
26
|
+
out_dir: str | Path,
|
|
27
|
+
*,
|
|
28
|
+
platform: str,
|
|
29
|
+
item_id: str | None = None,
|
|
30
|
+
url: str | None = None,
|
|
31
|
+
overwrite: bool = False,
|
|
32
|
+
raw: bool = False,
|
|
33
|
+
) -> Path:
|
|
34
|
+
platform_dir = Path(out_dir) / safe_filename(platform, fallback="unknown", max_length=80)
|
|
35
|
+
filename_base = filename_base_for_item(document.title, item_id=item_id, url=url)
|
|
36
|
+
output_path = unique_markdown_path(
|
|
37
|
+
platform_dir,
|
|
38
|
+
filename_base,
|
|
39
|
+
collision_key=url or item_id or document.title,
|
|
40
|
+
overwrite=overwrite,
|
|
41
|
+
)
|
|
42
|
+
output_path.write_text(render_document(document, raw=raw), encoding="utf-8", newline="\n")
|
|
43
|
+
return output_path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_item_result(
|
|
47
|
+
item: ContentItem,
|
|
48
|
+
result: TranscriptResult,
|
|
49
|
+
out_dir: str | Path,
|
|
50
|
+
*,
|
|
51
|
+
overwrite: bool = False,
|
|
52
|
+
captured_at: str | None = None,
|
|
53
|
+
download_assets: bool = False,
|
|
54
|
+
asset_fetcher: AssetFetcher | None = None,
|
|
55
|
+
raw: bool = False,
|
|
56
|
+
) -> Path:
|
|
57
|
+
document = render_markdown_document(item, result, captured_at=captured_at)
|
|
58
|
+
platform_dir = Path(out_dir) / safe_filename(item.platform, fallback="unknown", max_length=80)
|
|
59
|
+
filename_base = filename_base_for_item(document.title, item_id=item.id, url=item.url)
|
|
60
|
+
output_path = unique_markdown_path(
|
|
61
|
+
platform_dir,
|
|
62
|
+
filename_base,
|
|
63
|
+
collision_key=item.url or item.id or document.title,
|
|
64
|
+
overwrite=overwrite,
|
|
65
|
+
)
|
|
66
|
+
if download_assets:
|
|
67
|
+
localized_content, asset_notes = localize_markdown_images(
|
|
68
|
+
document.content,
|
|
69
|
+
output_path.parent,
|
|
70
|
+
fetcher=asset_fetcher,
|
|
71
|
+
)
|
|
72
|
+
document = replace(
|
|
73
|
+
document,
|
|
74
|
+
content=localized_content,
|
|
75
|
+
source_notes=[*document.source_notes, *asset_notes],
|
|
76
|
+
)
|
|
77
|
+
output_path.write_text(render_document(document, raw=raw), encoding="utf-8", newline="\n")
|
|
78
|
+
return output_path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def localize_markdown_images(
|
|
82
|
+
markdown: str,
|
|
83
|
+
article_dir: str | Path,
|
|
84
|
+
*,
|
|
85
|
+
fetcher: AssetFetcher | None = None,
|
|
86
|
+
timeout: int = 30,
|
|
87
|
+
) -> tuple[str, list[str]]:
|
|
88
|
+
"""Download Markdown image URLs under ``assets/images`` and rewrite links.
|
|
89
|
+
|
|
90
|
+
Asset failures are warnings, not extraction failures: the original remote
|
|
91
|
+
URL stays in the Markdown so the raw article remains usable.
|
|
92
|
+
"""
|
|
93
|
+
image_urls = _markdown_image_urls(markdown)
|
|
94
|
+
if not image_urls:
|
|
95
|
+
return markdown, ["asset_download: no Markdown image links found"]
|
|
96
|
+
|
|
97
|
+
fetch = fetcher or _default_asset_fetcher
|
|
98
|
+
image_dir = Path(article_dir) / "assets" / "images"
|
|
99
|
+
image_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
replacements: dict[str, str] = {}
|
|
102
|
+
notes = [f"asset_download: attempted {len(image_urls)} image(s)"]
|
|
103
|
+
success_count = 0
|
|
104
|
+
|
|
105
|
+
for index, url in enumerate(image_urls, start=1):
|
|
106
|
+
try:
|
|
107
|
+
content, content_type = fetch(_absolute_url(url), _asset_headers(url), timeout)
|
|
108
|
+
extension = _image_extension(url, content_type)
|
|
109
|
+
filename = f"img_{index:03d}.{extension}"
|
|
110
|
+
output_path = image_dir / filename
|
|
111
|
+
output_path.write_bytes(content)
|
|
112
|
+
replacements[url] = f"assets/images/{filename}"
|
|
113
|
+
success_count += 1
|
|
114
|
+
except Exception as exc: # pragma: no cover - safety net covered via injected fetchers
|
|
115
|
+
summary = sanitize_error_summary(exc) or "unknown error"
|
|
116
|
+
notes.append(f"asset_download_failed: {sanitize_url_text(url)} ({summary})")
|
|
117
|
+
|
|
118
|
+
def replace_image(match: re.Match[str]) -> str:
|
|
119
|
+
alt_text = match.group(1)
|
|
120
|
+
remote_url = match.group(2).strip().strip("<>")
|
|
121
|
+
local_path = replacements.get(remote_url)
|
|
122
|
+
if local_path is None:
|
|
123
|
+
return match.group(0)
|
|
124
|
+
return f""
|
|
125
|
+
|
|
126
|
+
markdown = _MARKDOWN_IMAGE_RE.sub(replace_image, markdown)
|
|
127
|
+
|
|
128
|
+
notes.append(f"asset_download: downloaded {success_count}/{len(image_urls)} image(s)")
|
|
129
|
+
return markdown, notes
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _markdown_image_urls(markdown: str) -> list[str]:
|
|
133
|
+
urls: list[str] = []
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
for match in _MARKDOWN_IMAGE_RE.finditer(markdown):
|
|
136
|
+
url = match.group(2).strip().strip("<>")
|
|
137
|
+
if not _is_remote_url(url) or url in seen:
|
|
138
|
+
continue
|
|
139
|
+
seen.add(url)
|
|
140
|
+
urls.append(url)
|
|
141
|
+
return urls
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_remote_url(url: str) -> bool:
|
|
145
|
+
return url.startswith("http://") or url.startswith("https://") or url.startswith("//")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _absolute_url(url: str) -> str:
|
|
149
|
+
if url.startswith("//"):
|
|
150
|
+
return f"https:{url}"
|
|
151
|
+
return url
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _asset_headers(url: str) -> dict[str, str]:
|
|
155
|
+
parsed = urlparse(_absolute_url(url))
|
|
156
|
+
headers = {"User-Agent": "Mozilla/5.0 Openmd/0.0"}
|
|
157
|
+
if parsed.netloc.endswith("qpic.cn") or parsed.netloc.endswith("qq.com"):
|
|
158
|
+
headers["Referer"] = "https://mp.weixin.qq.com/"
|
|
159
|
+
return headers
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _default_asset_fetcher(
|
|
163
|
+
url: str,
|
|
164
|
+
headers: dict[str, str],
|
|
165
|
+
timeout: int,
|
|
166
|
+
) -> tuple[bytes, str | None]:
|
|
167
|
+
request = Request(url, headers=headers)
|
|
168
|
+
with urlopen(request, timeout=timeout) as response:
|
|
169
|
+
return response.read(), response.headers.get("Content-Type")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _image_extension(url: str, content_type: str | None) -> str:
|
|
173
|
+
parsed = urlparse(_absolute_url(url))
|
|
174
|
+
wx_fmt = parse_qs(parsed.query).get("wx_fmt")
|
|
175
|
+
if wx_fmt:
|
|
176
|
+
return _safe_extension(wx_fmt[0])
|
|
177
|
+
suffix = Path(parsed.path).suffix.lstrip(".")
|
|
178
|
+
if suffix:
|
|
179
|
+
return _safe_extension(suffix)
|
|
180
|
+
if content_type and "/" in content_type:
|
|
181
|
+
subtype = content_type.split("/", 1)[1].split(";", 1)[0].strip()
|
|
182
|
+
if subtype == "jpeg":
|
|
183
|
+
return "jpg"
|
|
184
|
+
return _safe_extension(subtype)
|
|
185
|
+
return "png"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _safe_extension(value: str) -> str:
|
|
189
|
+
extension = value.lower().strip().lstrip(".")
|
|
190
|
+
if extension == "jpeg":
|
|
191
|
+
extension = "jpg"
|
|
192
|
+
if not _SAFE_EXTENSION_RE.match(extension):
|
|
193
|
+
return "png"
|
|
194
|
+
return extension
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Shared constants and helpers for Openmd V1 package artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .filename import safe_filename, short_hash
|
|
12
|
+
from .models import ContentItem, TranscriptResult
|
|
13
|
+
from .secrets import sanitize_url
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
OPENMD_PACKAGE_VERSION = "1.0-draft"
|
|
17
|
+
PACKAGE_SCHEMA_VERSION = "1.0-draft"
|
|
18
|
+
LEGACY_EXTRACTION_CONTRACT_VERSION = "1"
|
|
19
|
+
ACCEPTED_REQUIRED_COMPONENT_STATUSES: tuple[str, ...] = (
|
|
20
|
+
"verified",
|
|
21
|
+
"unavailable",
|
|
22
|
+
"not_applicable",
|
|
23
|
+
)
|
|
24
|
+
XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION = "xhs-mm-v2"
|
|
25
|
+
XIAOHONGSHU_REQUIRED_COMPONENTS_BY_MEDIA_KIND: dict[str, tuple[str, ...]] = {
|
|
26
|
+
"video": ("native_text", "video_transcript"),
|
|
27
|
+
"image_text": ("native_text", "image_ocr"),
|
|
28
|
+
"mixed": ("native_text",),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
PACKAGE_PROFILES: tuple[str, ...] = (
|
|
32
|
+
"article",
|
|
33
|
+
"media",
|
|
34
|
+
"conversation",
|
|
35
|
+
"document",
|
|
36
|
+
"process",
|
|
37
|
+
)
|
|
38
|
+
PROFILE_SECTIONS: dict[str, str] = {
|
|
39
|
+
"article": "Content",
|
|
40
|
+
"media": "Transcript",
|
|
41
|
+
"conversation": "Conversation",
|
|
42
|
+
"document": "Content",
|
|
43
|
+
"process": "Steps",
|
|
44
|
+
}
|
|
45
|
+
PACKAGE_STATUS_VALUES: tuple[str, ...] = ("verified", "partial", "failed")
|
|
46
|
+
PACKAGE_SOURCE_LEVEL_VALUES: tuple[str, ...] = (
|
|
47
|
+
"native_text",
|
|
48
|
+
"native_caption",
|
|
49
|
+
"auto_caption",
|
|
50
|
+
"platform_transcript",
|
|
51
|
+
"third_party_extractor",
|
|
52
|
+
"browser_rendered",
|
|
53
|
+
"manual_export",
|
|
54
|
+
"ocr",
|
|
55
|
+
"asr",
|
|
56
|
+
"partial_extraction",
|
|
57
|
+
)
|
|
58
|
+
PERMISSION_SCOPE_VALUES: tuple[str, ...] = (
|
|
59
|
+
"public",
|
|
60
|
+
"user_accessible",
|
|
61
|
+
"local_file",
|
|
62
|
+
"user_export",
|
|
63
|
+
"manual_export",
|
|
64
|
+
"unknown",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
REQUIRED_METADATA_FIELDS: tuple[str, ...] = (
|
|
68
|
+
"openmd_version",
|
|
69
|
+
"package_schema_version",
|
|
70
|
+
"package_id",
|
|
71
|
+
"title",
|
|
72
|
+
"profile",
|
|
73
|
+
"modalities",
|
|
74
|
+
"status",
|
|
75
|
+
"source_level",
|
|
76
|
+
"created_at",
|
|
77
|
+
"updated_at",
|
|
78
|
+
"language",
|
|
79
|
+
"backend",
|
|
80
|
+
"warnings",
|
|
81
|
+
)
|
|
82
|
+
REQUIRED_SOURCE_FIELDS: tuple[str, ...] = (
|
|
83
|
+
"source_id",
|
|
84
|
+
"source_type",
|
|
85
|
+
"platform",
|
|
86
|
+
"input_ref",
|
|
87
|
+
"capture_method",
|
|
88
|
+
"permission_scope",
|
|
89
|
+
"captured_at",
|
|
90
|
+
"adapter",
|
|
91
|
+
"source_map",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def utc_now_iso() -> str:
|
|
98
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def utc_date_parts(timestamp: str | None = None) -> tuple[str, str, str]:
|
|
102
|
+
if timestamp:
|
|
103
|
+
normalized = timestamp.replace("Z", "+00:00")
|
|
104
|
+
try:
|
|
105
|
+
value = datetime.fromisoformat(normalized)
|
|
106
|
+
except ValueError:
|
|
107
|
+
value = datetime.now(timezone.utc)
|
|
108
|
+
else:
|
|
109
|
+
value = datetime.now(timezone.utc)
|
|
110
|
+
if value.tzinfo is None:
|
|
111
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
112
|
+
value = value.astimezone(timezone.utc)
|
|
113
|
+
return f"{value.year:04d}", f"{value.month:02d}", value.date().isoformat()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def safe_identifier(value: str | None, *, fallback: str = "item") -> str:
|
|
117
|
+
text = safe_filename(value, fallback=fallback, max_length=120)
|
|
118
|
+
text = _IDENTIFIER_RE.sub("_", text).strip("._-")
|
|
119
|
+
return text or fallback
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def package_profile_for_item(item: ContentItem, result: TranscriptResult | None = None) -> str:
|
|
123
|
+
profile = item.raw_meta.get("profile") if isinstance(item.raw_meta, dict) else None
|
|
124
|
+
if isinstance(profile, str) and profile in PACKAGE_PROFILES:
|
|
125
|
+
return profile
|
|
126
|
+
if item.source_type in {"video", "audio"}:
|
|
127
|
+
return "media"
|
|
128
|
+
if item.source_type == "conversation":
|
|
129
|
+
return "conversation"
|
|
130
|
+
if item.source_type in {"document", "image_text"}:
|
|
131
|
+
return "document"
|
|
132
|
+
if item.source_type == "process":
|
|
133
|
+
return "process"
|
|
134
|
+
if result and result.metadata.get("profile") in PACKAGE_PROFILES:
|
|
135
|
+
return str(result.metadata["profile"])
|
|
136
|
+
return "article"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def package_source_level(source_level: str) -> str:
|
|
140
|
+
if source_level in {"media_asr", "live_capture_asr"}:
|
|
141
|
+
return "asr"
|
|
142
|
+
if source_level in PACKAGE_SOURCE_LEVEL_VALUES:
|
|
143
|
+
return source_level
|
|
144
|
+
return "partial_extraction"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def modalities_for_item(item: ContentItem, profile: str) -> list[str]:
|
|
148
|
+
if item.source_type == "video":
|
|
149
|
+
return ["video", "audio", "text"]
|
|
150
|
+
if item.source_type == "audio":
|
|
151
|
+
return ["audio", "text"]
|
|
152
|
+
if item.source_type == "image_text":
|
|
153
|
+
return ["image", "text"]
|
|
154
|
+
if item.source_type == "document":
|
|
155
|
+
return ["document", "text"]
|
|
156
|
+
if profile == "conversation":
|
|
157
|
+
return ["text"]
|
|
158
|
+
return ["text"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def normalized_input_ref(item: ContentItem) -> str:
|
|
162
|
+
value = str(item.url)
|
|
163
|
+
if value.startswith("http://") or value.startswith("https://"):
|
|
164
|
+
return sanitize_url(value)
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def content_id_for_item(item: ContentItem) -> str:
|
|
169
|
+
native_id = item.id
|
|
170
|
+
if not native_id and isinstance(item.raw_meta, dict):
|
|
171
|
+
native_id = item.raw_meta.get("content_id")
|
|
172
|
+
if native_id:
|
|
173
|
+
return safe_identifier(str(native_id), fallback=short_hash(normalized_input_ref(item)))
|
|
174
|
+
return short_hash(f"{item.platform}:{normalized_input_ref(item)}", length=12)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def package_id_for_item(item: ContentItem) -> str:
|
|
178
|
+
return f"{safe_identifier(item.platform, fallback='platform')}_{content_id_for_item(item)}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def package_dir_name(
|
|
182
|
+
item: ContentItem,
|
|
183
|
+
result: TranscriptResult,
|
|
184
|
+
*,
|
|
185
|
+
created_at: str,
|
|
186
|
+
version_suffix: str | None = None,
|
|
187
|
+
) -> str:
|
|
188
|
+
title = result.title or item.title
|
|
189
|
+
if not title and isinstance(item.raw_meta, dict):
|
|
190
|
+
title = item.raw_meta.get("title")
|
|
191
|
+
_, _, date_part = utc_date_parts(item.published_at or result.published_at or created_at)
|
|
192
|
+
platform = safe_identifier(item.platform, fallback="platform")
|
|
193
|
+
title_part = safe_filename(title, fallback=content_id_for_item(item), max_length=80)
|
|
194
|
+
identity_hash = short_hash(f"{package_id_for_item(item)}:{normalized_input_ref(item)}")
|
|
195
|
+
suffix = f"__{version_suffix}" if version_suffix else ""
|
|
196
|
+
return f"{date_part}__{platform}__{title_part}__{identity_hash}{suffix}.openmd"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def package_root_for_args(out_dir: str | Path, package_root: str | Path | None) -> Path:
|
|
200
|
+
if package_root is not None:
|
|
201
|
+
return Path(package_root)
|
|
202
|
+
return Path(out_dir) / "packages"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def dedupe_key_for_item(item: ContentItem) -> str:
|
|
206
|
+
return f"{safe_identifier(item.platform, fallback='platform')}:{normalized_input_ref(item)}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def compact_warning_list(values: list[Any]) -> list[str]:
|
|
210
|
+
warnings: list[str] = []
|
|
211
|
+
for value in values:
|
|
212
|
+
text = str(value).strip()
|
|
213
|
+
if text and text not in warnings:
|
|
214
|
+
warnings.append(text)
|
|
215
|
+
return warnings
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def extraction_contract_version_for(
|
|
219
|
+
item: ContentItem,
|
|
220
|
+
result: TranscriptResult | None = None,
|
|
221
|
+
) -> str:
|
|
222
|
+
if result is not None and result.extraction_contract_version:
|
|
223
|
+
return str(result.extraction_contract_version)
|
|
224
|
+
if isinstance(item.raw_meta, dict) and item.raw_meta.get("extraction_contract_version"):
|
|
225
|
+
return str(item.raw_meta["extraction_contract_version"])
|
|
226
|
+
return LEGACY_EXTRACTION_CONTRACT_VERSION
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def required_component_ids_for_item(item: ContentItem) -> tuple[str, ...]:
|
|
230
|
+
if not isinstance(item.raw_meta, dict):
|
|
231
|
+
return ()
|
|
232
|
+
raw = item.raw_meta.get("required_components")
|
|
233
|
+
if not isinstance(raw, (list, tuple)):
|
|
234
|
+
return ()
|
|
235
|
+
values: list[str] = []
|
|
236
|
+
for entry in raw:
|
|
237
|
+
if isinstance(entry, str):
|
|
238
|
+
component_id = entry.strip()
|
|
239
|
+
elif isinstance(entry, dict) and entry.get("required", True):
|
|
240
|
+
component_id = str(entry.get("id") or "").strip()
|
|
241
|
+
else:
|
|
242
|
+
component_id = ""
|
|
243
|
+
if component_id and component_id not in values:
|
|
244
|
+
values.append(component_id)
|
|
245
|
+
return tuple(values)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def package_metadata_satisfies_extraction_contract(
|
|
249
|
+
metadata: dict[str, Any],
|
|
250
|
+
item: ContentItem,
|
|
251
|
+
) -> bool:
|
|
252
|
+
requested_version = extraction_contract_version_for(item)
|
|
253
|
+
stored_version = metadata.get("extraction_contract_version")
|
|
254
|
+
if stored_version is None:
|
|
255
|
+
return (
|
|
256
|
+
requested_version == LEGACY_EXTRACTION_CONTRACT_VERSION
|
|
257
|
+
and not required_component_ids_for_item(item)
|
|
258
|
+
)
|
|
259
|
+
if str(stored_version) != requested_version:
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
required_ids = required_component_ids_for_item(item)
|
|
263
|
+
if not required_ids:
|
|
264
|
+
return True
|
|
265
|
+
raw_components = metadata.get("components")
|
|
266
|
+
if not isinstance(raw_components, list):
|
|
267
|
+
return False
|
|
268
|
+
components = {
|
|
269
|
+
str(component.get("id")): component
|
|
270
|
+
for component in raw_components
|
|
271
|
+
if isinstance(component, dict) and component.get("id")
|
|
272
|
+
}
|
|
273
|
+
return all(
|
|
274
|
+
component_id in components and _component_satisfies_requirement(components[component_id])
|
|
275
|
+
for component_id in required_ids
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def apply_platform_extraction_contract(item: ContentItem) -> ContentItem:
|
|
280
|
+
"""Attach the platform contract before skip-existing runs."""
|
|
281
|
+
if item.platform != "xiaohongshu":
|
|
282
|
+
return item
|
|
283
|
+
raw_meta = dict(item.raw_meta) if isinstance(item.raw_meta, dict) else {}
|
|
284
|
+
has_contract_marker = bool(raw_meta.get("extraction_contract_version"))
|
|
285
|
+
has_required_components = bool(required_component_ids_for_item(item))
|
|
286
|
+
media_kind = str(raw_meta.get("media_kind") or "").strip()
|
|
287
|
+
if not has_contract_marker and not has_required_components and not media_kind:
|
|
288
|
+
return item
|
|
289
|
+
if not has_required_components and media_kind:
|
|
290
|
+
required = XIAOHONGSHU_REQUIRED_COMPONENTS_BY_MEDIA_KIND.get(media_kind)
|
|
291
|
+
if required:
|
|
292
|
+
raw_meta["required_components"] = list(required)
|
|
293
|
+
has_required_components = True
|
|
294
|
+
if has_contract_marker or has_required_components:
|
|
295
|
+
raw_meta.setdefault(
|
|
296
|
+
"extraction_contract_version", XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION
|
|
297
|
+
)
|
|
298
|
+
if raw_meta == item.raw_meta:
|
|
299
|
+
return item
|
|
300
|
+
return replace(item, raw_meta=raw_meta)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def extraction_result_satisfies_contract(
|
|
304
|
+
item: ContentItem,
|
|
305
|
+
result: TranscriptResult,
|
|
306
|
+
) -> bool:
|
|
307
|
+
metadata = {
|
|
308
|
+
"extraction_contract_version": extraction_contract_version_for(item, result),
|
|
309
|
+
"components": [
|
|
310
|
+
{
|
|
311
|
+
"id": component.id,
|
|
312
|
+
"status": component.status,
|
|
313
|
+
"terminal_reason": component.terminal_reason,
|
|
314
|
+
"error_code": component.error_code,
|
|
315
|
+
"failure_class": component.failure_class,
|
|
316
|
+
}
|
|
317
|
+
for component in result.normalized_components
|
|
318
|
+
],
|
|
319
|
+
}
|
|
320
|
+
return package_metadata_satisfies_extraction_contract(metadata, item)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _component_satisfies_requirement(component: dict[str, Any]) -> bool:
|
|
324
|
+
status = component.get("status")
|
|
325
|
+
if status in {"verified", "not_applicable"}:
|
|
326
|
+
return True
|
|
327
|
+
if status != "unavailable":
|
|
328
|
+
return False
|
|
329
|
+
return (
|
|
330
|
+
isinstance(component.get("terminal_reason"), str)
|
|
331
|
+
and bool(str(component["terminal_reason"]).strip())
|
|
332
|
+
and isinstance(component.get("error_code"), str)
|
|
333
|
+
and bool(str(component["error_code"]).strip())
|
|
334
|
+
and component.get("failure_class") == "source_unavailable"
|
|
335
|
+
)
|