openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,666 @@
1
+ """Writer for V1 ``item.openmd/`` artifact packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import shutil
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from .filename import short_hash
14
+ from .execution_policy import require_matching_fingerprint
15
+ from .models import ContentItem, TranscriptResult
16
+ from .package_contract import (
17
+ OPENMD_PACKAGE_VERSION,
18
+ PACKAGE_SCHEMA_VERSION,
19
+ PROFILE_SECTIONS,
20
+ compact_warning_list,
21
+ content_id_for_item,
22
+ dedupe_key_for_item,
23
+ extraction_contract_version_for,
24
+ extraction_result_satisfies_contract,
25
+ modalities_for_item,
26
+ normalized_input_ref,
27
+ package_dir_name,
28
+ package_id_for_item,
29
+ package_metadata_satisfies_extraction_contract,
30
+ package_profile_for_item,
31
+ package_root_for_args,
32
+ package_source_level,
33
+ utc_date_parts,
34
+ utc_now_iso,
35
+ )
36
+ from .package_validator import PackageValidationError, validate_package_or_raise
37
+ from .secrets import sanitize_error_summary, sanitize_url_text
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PackageWriteResult:
42
+ package_path: Path
43
+ package_id: str
44
+ operation: str
45
+ skipped_existing: bool = False
46
+
47
+
48
+ def write_item_package(
49
+ item: ContentItem,
50
+ result: TranscriptResult,
51
+ out_dir: str | Path,
52
+ *,
53
+ package_root: str | Path | None = None,
54
+ skip_existing: bool = True,
55
+ update: bool = False,
56
+ overwrite: bool = False,
57
+ new_version: bool = False,
58
+ captured_at: str | None = None,
59
+ execution_fingerprint: str | None = None,
60
+ ) -> PackageWriteResult:
61
+ """Write an extracted item as an ``.openmd`` package.
62
+
63
+ Default reruns are idempotent: if the same ``package_id`` already exists,
64
+ the package is returned without changing files. ``update`` and
65
+ ``overwrite`` both update the existing directory in place. ``new_version``
66
+ creates a sibling snapshot with the same stable ``package_id``.
67
+ """
68
+
69
+ root = package_root_for_args(out_dir, package_root)
70
+ package_id = package_id_for_item(item)
71
+ existing = find_existing_package_for_item(item, out_dir, package_root=package_root)
72
+ existing_path = existing.package_path if existing else None
73
+ existing_satisfies_contract = bool(
74
+ existing_path and _existing_package_satisfies_contract(existing_path, item)
75
+ )
76
+ if existing_path and execution_fingerprint:
77
+ validate_package_or_raise(existing_path)
78
+ metadata = json.loads((existing_path / "metadata.json").read_text(encoding="utf-8"))
79
+ mismatch = require_matching_fingerprint(metadata, execution_fingerprint)
80
+ if mismatch or not existing_satisfies_contract:
81
+ raise PackageValidationError(mismatch or "extraction_contract_mismatch")
82
+
83
+ if (
84
+ existing_path
85
+ and skip_existing
86
+ and not update
87
+ and not overwrite
88
+ and not new_version
89
+ and existing_satisfies_contract
90
+ ):
91
+ validate_package_or_raise(existing_path)
92
+ return existing
93
+
94
+ if not extraction_result_satisfies_contract(item, result):
95
+ raise PackageValidationError(
96
+ "extraction result does not satisfy the requested extraction contract"
97
+ )
98
+
99
+ now = captured_at or utc_now_iso()
100
+ contract_upgrade = bool(
101
+ existing_path
102
+ and skip_existing
103
+ and not update
104
+ and not overwrite
105
+ and not new_version
106
+ and not existing_satisfies_contract
107
+ )
108
+ created_at = (
109
+ _existing_created_at(existing_path)
110
+ if existing_path and (update or overwrite or contract_upgrade)
111
+ else now
112
+ )
113
+
114
+ if existing_path and (update or overwrite or contract_upgrade) and not new_version:
115
+ package_path = existing_path
116
+ operation = (
117
+ "upgraded_contract"
118
+ if contract_upgrade
119
+ else ("updated" if update else "overwritten")
120
+ )
121
+ else:
122
+ version_suffix = _version_suffix(now) if existing_path and new_version else None
123
+ package_path = _new_package_path(root, item, result, created_at=created_at, version_suffix=version_suffix)
124
+ operation = "new_version" if existing_path and new_version else "created"
125
+
126
+ staging_path = _staging_package_path(package_path)
127
+ _write_package_files(
128
+ staging_path,
129
+ item,
130
+ result,
131
+ created_at=created_at,
132
+ updated_at=now,
133
+ execution_fingerprint=execution_fingerprint,
134
+ )
135
+ try:
136
+ validate_package_or_raise(staging_path)
137
+ except Exception:
138
+ shutil.rmtree(staging_path, ignore_errors=True)
139
+ raise
140
+
141
+ _publish_validated_package(
142
+ staging_path,
143
+ package_path,
144
+ replace_existing=package_path.exists(),
145
+ )
146
+ return PackageWriteResult(
147
+ package_path=package_path,
148
+ package_id=package_id,
149
+ operation=operation,
150
+ skipped_existing=False,
151
+ )
152
+
153
+
154
+ def find_existing_package_for_item(
155
+ item: ContentItem,
156
+ out_dir: str | Path,
157
+ *,
158
+ package_root: str | Path | None = None,
159
+ ) -> PackageWriteResult | None:
160
+ root = package_root_for_args(out_dir, package_root)
161
+ package_id = package_id_for_item(item)
162
+ existing_path = _find_existing_package(root, package_id)
163
+ if existing_path is None:
164
+ return None
165
+ return PackageWriteResult(
166
+ package_path=existing_path,
167
+ package_id=package_id,
168
+ operation="skipped_existing",
169
+ skipped_existing=True,
170
+ )
171
+
172
+
173
+ def find_corrupt_package_for_item(
174
+ item: ContentItem,
175
+ out_dir: str | Path,
176
+ *,
177
+ package_root: str | Path | None = None,
178
+ ) -> Path | None:
179
+ """Find an unreadable package whose immutable path identity matches ``item``.
180
+
181
+ A compact run must fail closed instead of treating a broken metadata file as
182
+ a missing package and publishing a second package for the same input.
183
+ """
184
+ root = package_root_for_args(out_dir, package_root)
185
+ if not root.exists():
186
+ return None
187
+ identity_hash = short_hash(f"{package_id_for_item(item)}:{normalized_input_ref(item)}")
188
+ for package_path in sorted(root.rglob("*.openmd")):
189
+ if ".tmp-" in package_path.name or ".bak-" in package_path.name:
190
+ continue
191
+ name = package_path.name
192
+ if not (name.endswith(f"__{identity_hash}.openmd") or f"__{identity_hash}__v" in name):
193
+ continue
194
+ try:
195
+ metadata = json.loads((package_path / "metadata.json").read_text(encoding="utf-8"))
196
+ except (OSError, json.JSONDecodeError):
197
+ return package_path
198
+ if not isinstance(metadata, dict) or metadata.get("package_id") != package_id_for_item(item):
199
+ return package_path
200
+ return None
201
+
202
+
203
+ def _new_package_path(
204
+ root: Path,
205
+ item: ContentItem,
206
+ result: TranscriptResult,
207
+ *,
208
+ created_at: str,
209
+ version_suffix: str | None = None,
210
+ ) -> Path:
211
+ year, month, _ = utc_date_parts(item.published_at or result.published_at or created_at)
212
+ directory = root / year / month
213
+ directory.mkdir(parents=True, exist_ok=True)
214
+ candidate = directory / package_dir_name(item, result, created_at=created_at, version_suffix=version_suffix)
215
+ if not candidate.exists():
216
+ return candidate
217
+
218
+ counter = 2
219
+ base_name = candidate.name.removesuffix(".openmd")
220
+ while True:
221
+ next_candidate = directory / f"{base_name}-{counter}.openmd"
222
+ if not next_candidate.exists():
223
+ return next_candidate
224
+ counter += 1
225
+
226
+
227
+ def _write_package_files(
228
+ package_path: Path,
229
+ item: ContentItem,
230
+ result: TranscriptResult,
231
+ *,
232
+ created_at: str,
233
+ updated_at: str,
234
+ execution_fingerprint: str | None,
235
+ ) -> None:
236
+ package_path.mkdir(parents=True, exist_ok=True)
237
+ (package_path / "assets").mkdir(exist_ok=True)
238
+ metadata = build_package_metadata(
239
+ item,
240
+ result,
241
+ created_at=created_at,
242
+ updated_at=updated_at,
243
+ execution_fingerprint=execution_fingerprint,
244
+ )
245
+ sources = build_package_sources(item, result, captured_at=updated_at)
246
+ index_md = render_package_index(item, result, metadata=metadata, sources=sources)
247
+
248
+ _write_json(package_path / "metadata.json", metadata)
249
+ _write_json(package_path / "sources.json", sources)
250
+ (package_path / "index.md").write_text(index_md, encoding="utf-8", newline="\n")
251
+
252
+ if metadata["profile"] == "media":
253
+ (package_path / "transcript.md").write_text(
254
+ _render_transcript_sidecar(result),
255
+ encoding="utf-8",
256
+ newline="\n",
257
+ )
258
+
259
+
260
+ def build_package_metadata(
261
+ item: ContentItem,
262
+ result: TranscriptResult,
263
+ *,
264
+ created_at: str,
265
+ updated_at: str,
266
+ execution_fingerprint: str | None = None,
267
+ ) -> dict[str, Any]:
268
+ profile = package_profile_for_item(item, result)
269
+ title = result.title or item.title
270
+ if not title and isinstance(item.raw_meta, dict):
271
+ title = item.raw_meta.get("title")
272
+ author = result.author or item.author
273
+ published_at = result.published_at or item.published_at
274
+ source_level = package_source_level(str(result.source_level))
275
+ warnings = compact_warning_list([*result.source_notes])
276
+ if result.error_summary:
277
+ warnings.append(str(result.error_summary))
278
+
279
+ metadata: dict[str, Any] = {
280
+ "openmd_version": OPENMD_PACKAGE_VERSION,
281
+ "package_schema_version": PACKAGE_SCHEMA_VERSION,
282
+ "package_id": package_id_for_item(item),
283
+ "content_id": content_id_for_item(item),
284
+ "dedupe_key": dedupe_key_for_item(item),
285
+ "title": _single_line_text(title or content_id_for_item(item)),
286
+ "profile": profile,
287
+ "modalities": modalities_for_item(item, profile),
288
+ "status": result.status,
289
+ "source_level": source_level,
290
+ "extraction_contract_version": extraction_contract_version_for(item, result),
291
+ "components": [
292
+ {
293
+ "id": component.id,
294
+ "kind": component.kind,
295
+ "status": component.status,
296
+ "source_level": package_source_level(str(component.source_level)),
297
+ "backend": component.backend,
298
+ "text_ref": component.text_ref,
299
+ "warnings": [
300
+ sanitize_url_text(value)
301
+ for value in compact_warning_list(component.warnings)
302
+ ],
303
+ "terminal_reason": component.terminal_reason,
304
+ "error_code": component.error_code,
305
+ "failure_class": component.failure_class,
306
+ }
307
+ for component in result.normalized_components
308
+ ],
309
+ "created_at": created_at,
310
+ "updated_at": updated_at,
311
+ "language": _metadata_language(item, result),
312
+ "backend": {
313
+ "name": result.backend,
314
+ "route": "openmd",
315
+ "version": None,
316
+ },
317
+ "warnings": [sanitize_url_text(value) for value in warnings],
318
+ "source_platform": item.platform,
319
+ "source_url": normalized_input_ref(item),
320
+ }
321
+ if author:
322
+ metadata["author"] = _single_line_text(author)
323
+ if published_at:
324
+ metadata["published_at"] = published_at
325
+ if execution_fingerprint:
326
+ metadata["execution_fingerprint"] = execution_fingerprint
327
+ metadata["route_trace"] = {key: result.metadata[key] for key in ("card_id", "route_version", "overlay_applied") if key in result.metadata}
328
+ from .secrets import asr_execution_metrics
329
+
330
+ asr_execution = asr_execution_metrics(result.metadata)
331
+ if asr_execution:
332
+ metadata["asr_execution"] = asr_execution
333
+ if item.platform in {"pdf", "screenshot"}:
334
+ from .secrets import sanitize_document_metadata
335
+
336
+ metadata["document_metadata"] = sanitize_document_metadata({key: result.metadata[key] for key in ("pages", "office_loss_check", "office_recovery", "office_external_resources_fetched", "source_format", "mixed_routes", "pymupdf_version", "terminal_partial") if key in result.metadata})
337
+ return metadata
338
+
339
+
340
+ def build_package_sources(
341
+ item: ContentItem,
342
+ result: TranscriptResult,
343
+ *,
344
+ captured_at: str,
345
+ ) -> dict[str, Any]:
346
+ adapter_name = result.backend or f"openmd.{item.platform}"
347
+ input_ref = normalized_input_ref(item)
348
+ source = {
349
+ "source_id": "src_001",
350
+ "source_type": item.source_type,
351
+ "platform": item.platform,
352
+ "input_ref": input_ref,
353
+ "capture_method": adapter_name,
354
+ "permission_scope": _permission_scope(item),
355
+ "captured_at": captured_at,
356
+ "adapter": {
357
+ "name": adapter_name,
358
+ "version": None,
359
+ },
360
+ "source_map": _ocr_source_map_entries(item, result, input_ref) or [_primary_source_map_entry(item, result, input_ref)],
361
+ }
362
+ return {
363
+ "package_schema_version": PACKAGE_SCHEMA_VERSION,
364
+ "primary_source_id": "src_001",
365
+ "sources": [source],
366
+ }
367
+
368
+
369
+ def render_package_index(
370
+ item: ContentItem,
371
+ result: TranscriptResult,
372
+ *,
373
+ metadata: dict[str, Any],
374
+ sources: dict[str, Any],
375
+ ) -> str:
376
+ profile = str(metadata["profile"])
377
+ section = PROFILE_SECTIONS[profile]
378
+ title = _single_line_text(str(metadata["title"]))
379
+ source = sources["sources"][0]
380
+ text = sanitize_url_text(result.text.strip())
381
+ if not text and result.status == "failed":
382
+ text = sanitize_url_text(result.error_summary or "Extraction failed.")
383
+
384
+ lines = [
385
+ "---",
386
+ f"title: {_yaml_scalar(title)}",
387
+ f"profile: {profile}",
388
+ f"source_platform: {_yaml_scalar(str(metadata['source_platform']))}",
389
+ f"status: {metadata['status']}",
390
+ f"source_level: {metadata['source_level']}",
391
+ f"created_at: {metadata['created_at']}",
392
+ "---",
393
+ "",
394
+ f"# {title}",
395
+ "",
396
+ "## Source",
397
+ "",
398
+ f"- Platform: {item.platform}",
399
+ f"- Input: {source['input_ref']}",
400
+ f"- Backend: {metadata['backend']['name']}",
401
+ f"- Status: {metadata['status']}",
402
+ f"- Source level: {metadata['source_level']}",
403
+ ]
404
+ if metadata.get("author"):
405
+ lines.append(f"- Author: {metadata['author']}")
406
+ if metadata.get("published_at"):
407
+ lines.append(f"- Published at: {metadata['published_at']}")
408
+ warnings = metadata.get("warnings") or []
409
+ if warnings:
410
+ lines.extend(["", "### Warnings", ""])
411
+ lines.extend(f"- {warning}" for warning in warnings)
412
+
413
+ lines.extend(["", f"## {section}", ""])
414
+ lines.append(text or "_No extracted content._")
415
+ lines.append("")
416
+ return "\n".join(lines)
417
+
418
+
419
+ def _render_transcript_sidecar(result: TranscriptResult) -> str:
420
+ title = result.title or "Transcript"
421
+ text = sanitize_url_text(result.text.strip())
422
+ return f"# {title}\n\n{text}\n"
423
+
424
+
425
+ def _write_json(path: Path, value: dict[str, Any]) -> None:
426
+ path.write_text(
427
+ json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
428
+ encoding="utf-8",
429
+ newline="\n",
430
+ )
431
+
432
+
433
+ def _find_existing_package(root: Path, package_id: str) -> Path | None:
434
+ if not root.exists():
435
+ return None
436
+ for metadata_path in sorted(root.rglob("metadata.json")):
437
+ package_path = metadata_path.parent
438
+ if package_path.suffix != ".openmd":
439
+ continue
440
+ if ".tmp-" in package_path.name or ".bak-" in package_path.name:
441
+ continue
442
+ try:
443
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
444
+ except (OSError, json.JSONDecodeError):
445
+ continue
446
+ if isinstance(metadata, dict) and metadata.get("package_id") == package_id:
447
+ return package_path
448
+ return None
449
+
450
+
451
+ def _staging_package_path(package_path: Path) -> Path:
452
+ while True:
453
+ candidate = package_path.with_name(f"{package_path.stem}.tmp-{uuid4().hex[:8]}.openmd")
454
+ if not candidate.exists():
455
+ return candidate
456
+
457
+
458
+ def _publish_validated_package(
459
+ staging_path: Path,
460
+ package_path: Path,
461
+ *,
462
+ replace_existing: bool,
463
+ ) -> None:
464
+ if not replace_existing:
465
+ staging_path.rename(package_path)
466
+ return
467
+
468
+ backup_path = package_path.with_name(f"{package_path.stem}.bak-{uuid4().hex[:8]}.openmd")
469
+ package_path.rename(backup_path)
470
+ try:
471
+ staging_path.rename(package_path)
472
+ except Exception:
473
+ if package_path.exists():
474
+ shutil.rmtree(package_path, ignore_errors=True)
475
+ if backup_path.exists():
476
+ backup_path.rename(package_path)
477
+ raise
478
+ else:
479
+ shutil.rmtree(backup_path, ignore_errors=True)
480
+
481
+
482
+ def _existing_created_at(package_path: Path | None) -> str:
483
+ if package_path is None:
484
+ return utc_now_iso()
485
+ try:
486
+ metadata = json.loads((package_path / "metadata.json").read_text(encoding="utf-8"))
487
+ except (OSError, json.JSONDecodeError):
488
+ return utc_now_iso()
489
+ value = metadata.get("created_at") if isinstance(metadata, dict) else None
490
+ return str(value) if value else utc_now_iso()
491
+
492
+
493
+ def _existing_package_satisfies_contract(package_path: Path, item: ContentItem) -> bool:
494
+ try:
495
+ metadata = json.loads((package_path / "metadata.json").read_text(encoding="utf-8"))
496
+ except (OSError, json.JSONDecodeError):
497
+ return False
498
+ return isinstance(metadata, dict) and package_metadata_satisfies_extraction_contract(
499
+ metadata, item
500
+ )
501
+
502
+
503
+ def _version_suffix(timestamp: str) -> str:
504
+ return "v" + timestamp.replace("+00:00", "Z").replace(":", "").replace("-", "")
505
+
506
+
507
+ def _primary_source_map_entry(
508
+ item: ContentItem,
509
+ result: TranscriptResult,
510
+ input_ref: str,
511
+ ) -> dict[str, Any]:
512
+ return {
513
+ "target": "index.md#content",
514
+ "source_ref": _tier1_source_ref(item, result, input_ref),
515
+ "confidence": 1.0,
516
+ "notes": [],
517
+ }
518
+
519
+
520
+ def _ocr_source_map_entries(item: ContentItem, result: TranscriptResult, input_ref: str) -> list[dict[str, Any]]:
521
+ """Project only numeric OCR locators; never persist an arbitrary backend payload."""
522
+ if item.platform != "screenshot" or result.backend != "ocr.rapidocr":
523
+ return []
524
+ rows = result.metadata.get("source_map")
525
+ if not isinstance(rows, list):
526
+ return []
527
+ entries = []
528
+ for row in rows:
529
+ if not isinstance(row, dict) or type(row.get("line")) is not int or row["line"] < 1:
530
+ continue
531
+ bbox = row.get("bbox")
532
+ if not isinstance(bbox, list) or len(bbox) != 4 or not all(type(v) in (int, float) and math.isfinite(v) for v in bbox):
533
+ continue
534
+ source_ref = _tier1_source_ref(item, result, input_ref)
535
+ source_ref["bbox"] = bbox
536
+ entries.append({
537
+ "target": "index.md#content",
538
+ "source_ref": source_ref,
539
+ "confidence": None,
540
+ "notes": [f"OCR body line {row['line']}; pixel coordinates; reading order requires source verification."],
541
+ })
542
+ return entries
543
+
544
+
545
+ def _tier1_source_ref(
546
+ item: ContentItem,
547
+ result: TranscriptResult,
548
+ input_ref: str,
549
+ ) -> dict[str, Any]:
550
+ source_ref = {
551
+ "source_id": "src_001",
552
+ "url": input_ref,
553
+ "page": _locator_value(item, result, "page", "page_number", "page_index"),
554
+ "timestamp": _locator_value(item, result, "timestamp", "start_timestamp", "start_time", "start"),
555
+ "bbox": _locator_value(item, result, "bbox", "bounding_box"),
556
+ "message_id": _locator_value(item, result, "message_id", "msg_id", "msgid", "mid"),
557
+ "video_id": _video_locator(item, result),
558
+ "episode_id": _episode_locator(item, result),
559
+ "section_id": _section_locator(item, result),
560
+ }
561
+ return {key: _sanitize_locator(value) for key, value in source_ref.items()}
562
+
563
+
564
+ def _video_locator(item: ContentItem, result: TranscriptResult) -> Any:
565
+ value = _locator_value(
566
+ item,
567
+ result,
568
+ "video_id",
569
+ "bvid",
570
+ "bv_id",
571
+ "aid",
572
+ "aweme_id",
573
+ "content_id",
574
+ )
575
+ if value is not None:
576
+ return value
577
+ if item.source_type == "video" and item.id:
578
+ return item.id
579
+ return None
580
+
581
+
582
+ def _episode_locator(item: ContentItem, result: TranscriptResult) -> Any:
583
+ value = _locator_value(item, result, "episode_id", "episode", "eid")
584
+ if value is not None:
585
+ return value
586
+ if item.platform == "xiaoyuzhou" and item.id:
587
+ return item.id
588
+ return None
589
+
590
+
591
+ def _section_locator(item: ContentItem, result: TranscriptResult) -> Any:
592
+ return _locator_value(item, result, "section_id", "section", "chapter_id", "part_id")
593
+
594
+
595
+ def _locator_value(item: ContentItem, result: TranscriptResult, *keys: str) -> Any:
596
+ for source in (result.metadata, item.raw_meta):
597
+ if not isinstance(source, dict):
598
+ continue
599
+ for key in keys:
600
+ value = source.get(key)
601
+ if _has_locator_value(value):
602
+ return value
603
+ return None
604
+
605
+
606
+ def _has_locator_value(value: Any) -> bool:
607
+ if value is None:
608
+ return False
609
+ if isinstance(value, str):
610
+ return bool(value.strip())
611
+ if isinstance(value, (list, tuple, dict)):
612
+ return bool(value)
613
+ return True
614
+
615
+
616
+ def _sanitize_locator(value: Any) -> Any:
617
+ if value is None:
618
+ return None
619
+ if isinstance(value, str):
620
+ return sanitize_error_summary(value.strip())
621
+ if isinstance(value, (int, float, bool)):
622
+ return value
623
+ if isinstance(value, list):
624
+ return [_sanitize_locator(item) for item in value[:20]]
625
+ if isinstance(value, tuple):
626
+ return [_sanitize_locator(item) for item in value[:20]]
627
+ if isinstance(value, dict):
628
+ return {
629
+ str(sanitize_error_summary(str(key)) or ""): _sanitize_locator(child)
630
+ for key, child in value.items()
631
+ if _has_locator_value(child)
632
+ }
633
+ return sanitize_error_summary(str(value).strip())
634
+
635
+
636
+ def _permission_scope(item: ContentItem) -> str:
637
+ if isinstance(item.raw_meta, dict):
638
+ value = item.raw_meta.get("permission_scope")
639
+ if isinstance(value, str):
640
+ return value
641
+ if item.url.startswith("file:"):
642
+ return "local_file"
643
+ if not item.url.startswith(("http://", "https://")):
644
+ try:
645
+ if Path(str(item.url)).exists():
646
+ return "local_file"
647
+ except OSError:
648
+ pass
649
+ return "unknown"
650
+
651
+
652
+ def _metadata_language(item: ContentItem, result: TranscriptResult) -> str:
653
+ for source in (result.metadata, item.raw_meta if isinstance(item.raw_meta, dict) else {}):
654
+ value = source.get("language")
655
+ if isinstance(value, str) and value.strip():
656
+ return value.strip()
657
+ return "unknown"
658
+
659
+
660
+ def _single_line_text(value: str) -> str:
661
+ text = sanitize_url_text(str(value)).replace("\r", " ").replace("\n", " ")
662
+ return " ".join(text.split())
663
+
664
+
665
+ def _yaml_scalar(value: str) -> str:
666
+ return json.dumps(_single_line_text(value), ensure_ascii=False)