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
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"![{alt_text}]({local_path})"
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
+ )