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/receipt.py ADDED
@@ -0,0 +1,285 @@
1
+ """Bounded, redacted receipts for opt-in compact CLI runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from collections import Counter
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+
14
+ _MAX_COUNT = (1 << 63) - 1
15
+ _ERROR_CODES = frozenset(
16
+ {
17
+ "unknown",
18
+ "runtime_transient",
19
+ "source_unavailable",
20
+ "user_resolvable",
21
+ "code_resolvable",
22
+ "blocked",
23
+ "package_validation_failed",
24
+ }
25
+ )
26
+
27
+
28
+ class CompactReceiptRun:
29
+ TERMINAL_STATUSES = (
30
+ "verified",
31
+ "partial",
32
+ "terminal_gap",
33
+ "retryable_failure",
34
+ "blocked",
35
+ "failed",
36
+ "unattempted",
37
+ )
38
+
39
+ def __init__(self, out_dir: Path, run_dir: Path, run_id: str, command: str) -> None:
40
+ self.out_dir = out_dir
41
+ self.run_dir = run_dir
42
+ self.run_id = run_id
43
+ self.command = command
44
+ self.items_path = run_dir / "items.jsonl"
45
+ self.summary_path = run_dir / "summary.json"
46
+ self.checkpoint_path = run_dir / "checkpoint.json"
47
+ self._counts: Counter[str] = Counter()
48
+ self._errors: Counter[str] = Counter()
49
+ self._seen: set[str] = set()
50
+ self._occurrences = 0
51
+ self.expected: dict[str, int] = {}
52
+ self.requires_fresh_stdin = False
53
+
54
+ @classmethod
55
+ def create(cls, out_dir: str | Path, command: str) -> "CompactReceiptRun":
56
+ root = Path(out_dir)
57
+ run_id = uuid4().hex
58
+ run_dir = root / "runs" / run_id
59
+ run_dir.mkdir(parents=True, exist_ok=False)
60
+ run = cls(root, run_dir, run_id, command)
61
+ run.items_path.touch(exist_ok=False)
62
+ run.write_checkpoint()
63
+ return run
64
+
65
+ def has_item(self, identity: str) -> bool:
66
+ return identity in self._seen
67
+
68
+ @property
69
+ def has_items(self) -> bool:
70
+ return bool(self._seen)
71
+
72
+ def replace_expected(self, identities: dict[str, int]) -> None:
73
+ """Persist only redacted identities, never replayable input URLs."""
74
+ self.expected = dict(identities)
75
+ self._atomic_write_json(self.run_dir / "expected.json", self.expected)
76
+ self.write_checkpoint()
77
+
78
+ def add_occurrences(self, identity: str, occurrence: int) -> None:
79
+ """Reconcile a newly resolved alias against a completed item in this run."""
80
+ if identity not in self._seen:
81
+ return
82
+ rows, warnings = self.read_items_for_resume()
83
+ if warnings:
84
+ raise ValueError("cannot amend interrupted receipt")
85
+ for row in rows:
86
+ if row["stable_identity"] == identity:
87
+ row["input_occurrence"] += occurrence
88
+ break
89
+ self._atomic_write_text(
90
+ self.items_path,
91
+ "".join(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n" for row in rows),
92
+ )
93
+ self._occurrences += occurrence
94
+ self.write_checkpoint()
95
+
96
+ def write_checkpoint(self) -> None:
97
+ self._atomic_write_json(self.checkpoint_path, {
98
+ "schema_version": "openmd.compact-checkpoint.v1",
99
+ "run_id": self.run_id,
100
+ "completed": len(self._seen),
101
+ "expected_ref": "expected.json" if self.expected else None,
102
+ "expected_count": len(self.expected),
103
+ "requires_fresh_stdin": self.requires_fresh_stdin,
104
+ })
105
+
106
+ def record_item(
107
+ self,
108
+ *,
109
+ stable_identity: str,
110
+ input_occurrence: int,
111
+ status: str,
112
+ skipped: bool = False,
113
+ source_level: str | None = None,
114
+ quality_status: str | None = None,
115
+ error_code: str | None = None,
116
+ error_summary: str | None = None,
117
+ failure_class: str | None = None,
118
+ recovery_type: str | None = None,
119
+ artifact_ref: str | None = None,
120
+ route_trace: dict[str, Any] | None = None,
121
+ ) -> None:
122
+ if status not in self.TERMINAL_STATUSES:
123
+ raise ValueError(f"invalid terminal status: {status}")
124
+ if not stable_identity or stable_identity in self._seen:
125
+ raise ValueError("stable identity must be unique and non-empty")
126
+ if type(input_occurrence) is not int or input_occurrence < 1 or input_occurrence > _MAX_COUNT:
127
+ raise ValueError("input occurrence must be a positive integer")
128
+ normalized_error = self._normalize_error_code(error_code)
129
+ record = {
130
+ "stable_identity": stable_identity,
131
+ "input_occurrence": input_occurrence,
132
+ "status": status,
133
+ "skipped": skipped,
134
+ "artifact_ref": artifact_ref,
135
+ "source_level": source_level,
136
+ "quality_status": quality_status or "not_evaluated",
137
+ "error_code": normalized_error,
138
+ "failure_class": failure_class,
139
+ "recovery_type": recovery_type,
140
+ }
141
+ if route_trace:
142
+ record["route_trace"] = {key: route_trace[key] for key in ("card_id", "route_version", "overlay_applied") if key in route_trace}
143
+ # Error text may contain signed URLs, headers, or source content; never persist it.
144
+ del error_summary
145
+ with self.items_path.open("a", encoding="utf-8", newline="\n") as handle:
146
+ handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
147
+ handle.flush()
148
+ os.fsync(handle.fileno())
149
+ self._seen.add(stable_identity)
150
+ self._occurrences += input_occurrence
151
+ self._counts[status] += 1
152
+ if skipped:
153
+ self._counts["skipped"] += 1
154
+ if normalized_error:
155
+ self._errors[normalized_error] += 1
156
+ self.write_checkpoint()
157
+
158
+ def finalize(
159
+ self,
160
+ *,
161
+ discovery_complete: bool,
162
+ manifest_only: bool = False,
163
+ resume_supported: bool = False,
164
+ requires_fresh_stdin: bool = False,
165
+ limitations: list[str] | None = None,
166
+ failed: bool = False,
167
+ ) -> tuple[dict[str, Any], int]:
168
+ counts = {status: self._bounded_count(self._counts[status]) for status in self.TERMINAL_STATUSES}
169
+ counts["discovered"] = self._bounded_count(sum(counts.values()))
170
+ counts["skipped"] = self._bounded_count(self._counts["skipped"])
171
+ counts["input_occurrences"] = self._bounded_count(self._occurrences)
172
+ counts["duplicate_inputs"] = self._bounded_count(max(0, self._occurrences - counts["discovered"]))
173
+ accounting_complete = not self.expected or (
174
+ set(self.expected) == self._seen and sum(self.expected.values()) == self._occurrences
175
+ )
176
+ execution_complete = accounting_complete and counts["unattempted"] == 0
177
+ status = self._status_for(counts, discovery_complete=discovery_complete, manifest_only=manifest_only)
178
+ if failed or not accounting_complete:
179
+ status = "failed"
180
+ accessible = counts["discovered"] if discovery_complete and not any(
181
+ counts[state] for state in ("terminal_gap", "retryable_failure", "blocked", "failed", "unattempted")
182
+ ) else None
183
+ coverage = counts["verified"] / accessible if accessible else None
184
+ all_limitations = list(limitations or [])
185
+ if counts["discovered"] == 0 and "empty_scope" not in all_limitations:
186
+ all_limitations.append("empty_scope")
187
+ if requires_fresh_stdin and "requires_fresh_stdin" not in all_limitations:
188
+ all_limitations.append("requires_fresh_stdin")
189
+ summary = {
190
+ "schema_version": "openmd.run-summary.v1",
191
+ "run_id": self.run_id,
192
+ "command": self.command,
193
+ "status": status,
194
+ "counts": counts,
195
+ "discovery_complete": discovery_complete,
196
+ "accounting_complete": accounting_complete,
197
+ "execution_complete": execution_complete,
198
+ "coverage": coverage,
199
+ "accessible": accessible,
200
+ "resume_supported": resume_supported,
201
+ "items_ref": "items.jsonl",
202
+ "checkpoint_ref": "checkpoint.json",
203
+ "cost": {"external_calls": None, "metered_amount": None},
204
+ "limitations": all_limitations,
205
+ }
206
+ self.requires_fresh_stdin = requires_fresh_stdin
207
+ self.write_checkpoint()
208
+ self._atomic_write_json(self.summary_path, summary)
209
+ top_errors = sorted(self._errors.items(), key=lambda item: (-item[1], item[0]))
210
+ selected = dict(top_errors[:5])
211
+ other = sum(count for _, count in top_errors[5:])
212
+ summary_ref = self.summary_path.relative_to(self.out_dir).as_posix()
213
+ payload = {
214
+ "schema_version": "openmd.receipt.v1",
215
+ "status": status,
216
+ "counts": counts,
217
+ "summary_ref": summary_ref,
218
+ "error_counts": selected,
219
+ "other_error_count": other,
220
+ }
221
+ rendered = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
222
+ if len(rendered.encode("utf-8")) > 2048:
223
+ raise ValueError("compact receipt exceeds 2048 UTF-8 bytes")
224
+ return payload, 0 if status in {"complete", "discovery_only"} else 1
225
+
226
+ def read_items_for_resume(self) -> tuple[list[dict[str, Any]], list[str]]:
227
+ raw = self.items_path.read_bytes()
228
+ lines = raw.splitlines(keepends=True)
229
+ rows: list[dict[str, Any]] = []
230
+ warnings: list[str] = []
231
+ for index, line in enumerate(lines):
232
+ final_line = index == len(lines) - 1
233
+ try:
234
+ rows.append(json.loads(line))
235
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
236
+ if final_line and not line.endswith((b"\n", b"\r")):
237
+ warnings.append("truncated_final_jsonl_line")
238
+ break
239
+ raise ValueError("malformed_jsonl_line") from exc
240
+ return rows, warnings
241
+
242
+ @staticmethod
243
+ def stable_identity(*parts: str | None) -> str:
244
+ material = "\x1f".join(part or "" for part in parts)
245
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
246
+
247
+ @staticmethod
248
+ def _normalize_error_code(value: str | None) -> str | None:
249
+ if not value:
250
+ return None
251
+ normalized = str(value).lower()
252
+ return normalized if normalized in _ERROR_CODES else "unknown"
253
+
254
+ @staticmethod
255
+ def _bounded_count(value: int) -> int:
256
+ if type(value) is not int or value < 0 or value > _MAX_COUNT:
257
+ raise ValueError("receipt count is outside signed 64-bit range")
258
+ return value
259
+
260
+ @classmethod
261
+ def _atomic_write_json(cls, path: Path, payload: dict[str, Any]) -> None:
262
+ cls._atomic_write_text(path, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
263
+
264
+ @staticmethod
265
+ def _atomic_write_text(path: Path, text: str) -> None:
266
+ temporary = path.with_suffix(path.suffix + ".tmp")
267
+ with temporary.open("w", encoding="utf-8", newline="\n") as handle:
268
+ handle.write(text)
269
+ handle.flush()
270
+ os.fsync(handle.fileno())
271
+ os.replace(temporary, path)
272
+
273
+ @classmethod
274
+ def _status_for(cls, counts: dict[str, int], *, discovery_complete: bool, manifest_only: bool) -> str:
275
+ if counts["discovered"] == 0:
276
+ return "discovery_only" if manifest_only and discovery_complete else "blocked"
277
+ if manifest_only and discovery_complete:
278
+ return "discovery_only"
279
+ if counts["failed"] or counts["retryable_failure"]:
280
+ return "partial" if counts["verified"] or counts["partial"] else "failed"
281
+ if counts["blocked"] and not (counts["verified"] or counts["partial"]):
282
+ return "blocked"
283
+ if not discovery_complete or counts["partial"] or counts["terminal_gap"] or counts["unattempted"] or counts["blocked"]:
284
+ return "partial"
285
+ return "complete"
@@ -0,0 +1,185 @@
1
+ """Markdown rendering for normalized Openmd extraction results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Any
10
+
11
+ from .models import ContentItem, MarkdownDocument, TranscriptResult
12
+ from .readable import format_transcript, markdown_label
13
+ from .secrets import sanitize_error_summary, sanitize_url_text
14
+
15
+
16
+ FRONTMATTER_FIELDS: tuple[str, ...] = (
17
+ "title",
18
+ "author",
19
+ "url",
20
+ "published",
21
+ "platform",
22
+ )
23
+
24
+ _SOURCE_LABELS = {
25
+ "native_text": "原文",
26
+ "native_caption": "原生字幕",
27
+ "auto_caption": "自动字幕",
28
+ "platform_transcript": "平台逐字稿",
29
+ "third_party_extractor": "第三方提取",
30
+ "browser_rendered": "浏览器提取",
31
+ "manual_export": "手动导出",
32
+ "ocr": "文字识别",
33
+ "asr": "语音识别",
34
+ "media_asr": "语音识别",
35
+ "live_capture_asr": "实时语音识别",
36
+ "partial_extraction": "部分提取",
37
+ }
38
+
39
+
40
+ def utc_now_iso() -> str:
41
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
42
+
43
+
44
+ def _frontmatter_value(value: Any) -> str:
45
+ if value is None or value == "":
46
+ return ""
47
+ if isinstance(value, bool):
48
+ return "true" if value else "false"
49
+ text = str(value).replace("\r", " ").replace("\n", " ")
50
+ # JSON strings are valid YAML scalars and avoid implicit YAML booleans,
51
+ # numbers, dates and special punctuation in external titles/authors.
52
+ return json.dumps(text, ensure_ascii=False)
53
+
54
+
55
+ def source_level_label(value: object) -> str:
56
+ return _SOURCE_LABELS.get(str(value), "提取文本")
57
+
58
+
59
+ def published_date(value: object) -> str:
60
+ """Normalize known timestamp formats; unknown source labels stay intact."""
61
+ text = str(value or "").strip().replace("\r", " ").replace("\n", " ")
62
+ if not text:
63
+ return ""
64
+ try:
65
+ if re.fullmatch(r"\d{8}", text):
66
+ return datetime.strptime(text, "%Y%m%d").date().isoformat()
67
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).date().isoformat()
68
+ except ValueError:
69
+ try:
70
+ return parsedate_to_datetime(text).date().isoformat()
71
+ except (TypeError, ValueError, OverflowError):
72
+ return text
73
+
74
+
75
+ def build_source_notes(result: TranscriptResult) -> list[str]:
76
+ notes = [f"backend: {result.backend}", f"source_level: {result.source_level}"]
77
+ for note in result.source_notes:
78
+ if note:
79
+ notes.append(f"notes: {note}")
80
+ if result.error_code:
81
+ notes.append(f"error_code: {result.error_code}")
82
+ if result.error_summary:
83
+ summary = sanitize_error_summary(result.error_summary)
84
+ if summary:
85
+ notes.append(f"error_summary: {summary}")
86
+ return notes
87
+
88
+
89
+ def render_markdown_document(
90
+ item: ContentItem,
91
+ result: TranscriptResult,
92
+ *,
93
+ captured_at: str | None = None,
94
+ ) -> MarkdownDocument:
95
+ title = result.title or item.title or item.id or "untitled"
96
+ image_post_author_text = (
97
+ item.platform == "douyin" and result.backend == "douyin.post-text"
98
+ and result.source_level == "native_text"
99
+ and result.metadata.get("transcript_method") == "image_post_text"
100
+ and result.metadata.get("media_kind") == "image_text"
101
+ and result.metadata.get("content_kind") == "author_text"
102
+ )
103
+ frontmatter = {
104
+ "platform": item.platform,
105
+ "url": sanitize_url_text(item.url),
106
+ "title": title,
107
+ "author": result.author or item.author,
108
+ "published_at": result.published_at or item.published_at,
109
+ "captured_at": captured_at or utc_now_iso(),
110
+ "source_type": "image_text" if image_post_author_text else item.source_type,
111
+ "image_post_author_text": image_post_author_text,
112
+ "status": result.status,
113
+ "source_level": result.source_level,
114
+ "backend": result.backend,
115
+ }
116
+ return MarkdownDocument(
117
+ frontmatter=frontmatter,
118
+ title=title,
119
+ content=result.text,
120
+ source_notes=build_source_notes(result),
121
+ )
122
+
123
+
124
+ def _source_footer(document: MarkdownDocument) -> str:
125
+ level = str(document.frontmatter.get("source_level") or "")
126
+ label = "作者原文(图文)" if document.frontmatter.get("image_post_author_text") else source_level_label(level)
127
+ parts = ["来源:" + label]
128
+ if level in {"auto_caption", "ocr", "asr", "media_asr", "live_capture_asr"}:
129
+ parts.append("识别错误保持原样")
130
+ status = document.frontmatter.get("status")
131
+ if status == "partial":
132
+ parts.append("内容可能不完整")
133
+ elif status == "failed":
134
+ parts.append("提取失败")
135
+ if any("识别模型可能自动纠正原文错字" in note for note in document.source_notes):
136
+ parts.append("识别模型可能自动纠正原文错字")
137
+ parts.append("未触发回退不代表无幻觉")
138
+ # Keep user-relevant coverage warnings, not command traces or backend IDs.
139
+ if any("Single post only" in note for note in document.source_notes):
140
+ parts.append("仅此条帖子,未验证整串讨论覆盖")
141
+ if any("视频标题未取得" in note for note in document.source_notes):
142
+ parts.append("视频标题未取得")
143
+ elif any("视频作者未取得" in note for note in document.source_notes):
144
+ parts.append("视频作者未取得")
145
+ failed_images = sum(
146
+ note.startswith(("asset_download_failed:", "notes: asset_download_failed:"))
147
+ for note in document.source_notes
148
+ )
149
+ if failed_images:
150
+ parts.append(f"{failed_images} 张图片下载失败,保留远程链接")
151
+ return "*" + ";".join(parts) + "。*"
152
+
153
+
154
+ def render_document(document: MarkdownDocument, *, raw: bool = False) -> str:
155
+ """Render a reading artifact; ``raw`` preserves the original body layout.
156
+
157
+ Structured extraction metadata remains on MarkdownDocument for runlogs and
158
+ package consumers; only the five human-facing source fields are printed.
159
+ """
160
+ lines = ["---"]
161
+ for field in FRONTMATTER_FIELDS:
162
+ value = document.frontmatter.get(field)
163
+ if field == "title":
164
+ value = document.title
165
+ if field == "published":
166
+ value = published_date(value or document.frontmatter.get("published_at"))
167
+ if value is None or (isinstance(value, str) and not value.strip()):
168
+ continue
169
+ lines.append(f"{field}: {_frontmatter_value(value)}")
170
+ content = document.content if raw or document.frontmatter.get("image_post_author_text") else format_transcript(
171
+ document.content,
172
+ source_type=document.frontmatter.get("source_type"),
173
+ platform=document.frontmatter.get("platform"),
174
+ )
175
+ heading = f"# {markdown_label(document.title)}"
176
+ if not raw and document.frontmatter.get("platform") != "x" and not document.frontmatter.get("image_post_author_text"):
177
+ # Source extractors may repeat a title as the first paragraph/heading.
178
+ # X post titles are derived from the body, so keep their first sentence.
179
+ # Promote only an exact match for sources with independent titles.
180
+ first, separator, rest = content.lstrip("\r\n").partition("\n")
181
+ if first.strip() in {document.title, "# " + document.title}:
182
+ content = rest.lstrip("\r\n") if separator else ""
183
+ prefix = "\n".join([*lines, "---", "", heading, "", ""])
184
+ # Do not rstrip raw content: trailing spaces/newlines can be meaningful.
185
+ return prefix + content + ("" if content.endswith("\n") else "\n") + "\n" + _source_footer(document) + "\n"
openmd/core/runlog.py ADDED
@@ -0,0 +1,208 @@
1
+ """Append-only lightweight runlog writer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .models import ContentItem, FAILURE_CLASS_VALUES, TranscriptResult
11
+ from .package_contract import extraction_contract_version_for
12
+ from .secrets import asr_execution_metrics, sanitize_runlog_entry, sanitize_source_scope
13
+
14
+
15
+ USER_RESOLVABLE_ERROR_TERMS: tuple[str, ...] = (
16
+ "auth",
17
+ "credential",
18
+ "login",
19
+ "permission",
20
+ "selector",
21
+ "target_scope",
22
+ "api_key_missing",
23
+ "session_expired",
24
+ "cache_stale",
25
+ "refresh_needed",
26
+ "export_required",
27
+ "target_missing",
28
+ )
29
+ SOURCE_UNAVAILABLE_ERROR_TERMS: tuple[str, ...] = (
30
+ "source_unavailable",
31
+ "source_empty",
32
+ "not_found",
33
+ "removed",
34
+ "deleted",
35
+ "empty_result",
36
+ "empty_content",
37
+ "no_transcript",
38
+ "subtitle_missing",
39
+ "no_delta",
40
+ "no_data",
41
+ )
42
+ RUNTIME_TRANSIENT_ERROR_TERMS: tuple[str, ...] = (
43
+ "timeout",
44
+ "rate_limit",
45
+ "locked",
46
+ "unreachable",
47
+ "temporarily",
48
+ "http_5",
49
+ "backend_exit",
50
+ "fetch_failed",
51
+ )
52
+
53
+
54
+ def utc_now_iso() -> str:
55
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
56
+
57
+
58
+ def default_runlog_path(out_dir: str | Path) -> Path:
59
+ return Path(out_dir) / "runlog.jsonl"
60
+
61
+
62
+ def append_runlog_entry(path: str | Path, entry: dict[str, Any]) -> dict[str, Any]:
63
+ runlog_path = Path(path)
64
+ runlog_path.parent.mkdir(parents=True, exist_ok=True)
65
+ sanitized = sanitize_runlog_entry(entry)
66
+ with runlog_path.open("a", encoding="utf-8", newline="\n") as file:
67
+ file.write(json.dumps(sanitized, ensure_ascii=False, sort_keys=True) + "\n")
68
+ return sanitized
69
+
70
+
71
+ def build_runlog_entry(
72
+ *,
73
+ input_url: str,
74
+ platform: str,
75
+ backend: str,
76
+ status: str,
77
+ source_type: str,
78
+ source_level: str,
79
+ output_path: str | Path | None = None,
80
+ package_id: str | None = None,
81
+ package_operation: str | None = None,
82
+ started_at: str | None = None,
83
+ finished_at: str | None = None,
84
+ item_count: int = 1,
85
+ success_count: int | None = None,
86
+ failed_count: int | None = None,
87
+ event_type: str = "item",
88
+ failure_class: str | None = None,
89
+ guardrail_signals: list[str] | None = None,
90
+ resume_pointer: str | None = None,
91
+ error_code: str | None = None,
92
+ error_summary: str | None = None,
93
+ extraction_contract_version: str | None = None,
94
+ components: list[dict[str, Any]] | None = None,
95
+ terminal_gap_count: int = 0,
96
+ route_trace: dict[str, Any] | None = None,
97
+ document_metadata: dict[str, Any] | None = None,
98
+ asr_execution: dict[str, Any] | None = None,
99
+ source_scope: dict[str, Any] | None = None,
100
+ ) -> dict[str, Any]:
101
+ success = success_count if success_count is not None else int(status in {"verified", "partial"})
102
+ failed = failed_count if failed_count is not None else int(status == "failed")
103
+ normalized_failure_class = normalize_failure_class(failure_class) or classify_failure(error_code)
104
+ return {
105
+ "event_type": event_type,
106
+ "input_url": input_url,
107
+ "platform": platform,
108
+ "backend": backend,
109
+ "status": status,
110
+ "source_type": source_type,
111
+ "source_level": source_level,
112
+ "failure_class": normalized_failure_class,
113
+ "guardrail_signals": guardrail_signals or [],
114
+ "resume_pointer": resume_pointer,
115
+ "output_path": str(output_path) if output_path else "",
116
+ "package_id": package_id,
117
+ "package_operation": package_operation,
118
+ "started_at": started_at or utc_now_iso(),
119
+ "finished_at": finished_at or utc_now_iso(),
120
+ "item_count": item_count,
121
+ "success_count": success,
122
+ "failed_count": failed,
123
+ "error_code": error_code,
124
+ "error_summary": error_summary,
125
+ "extraction_contract_version": extraction_contract_version,
126
+ "components": components or [],
127
+ "terminal_gap_count": terminal_gap_count,
128
+ "route_trace": route_trace or {},
129
+ "document_metadata": document_metadata or {},
130
+ "asr_execution": asr_execution or {},
131
+ "source_scope": sanitize_source_scope(source_scope),
132
+ }
133
+
134
+
135
+ def build_item_runlog_entry(
136
+ item: ContentItem,
137
+ result: TranscriptResult,
138
+ *,
139
+ output_path: str | Path | None = None,
140
+ package_id: str | None = None,
141
+ package_operation: str | None = None,
142
+ started_at: str | None = None,
143
+ finished_at: str | None = None,
144
+ ) -> dict[str, Any]:
145
+ failure_class = None
146
+ if isinstance(result.metadata, dict):
147
+ raw_failure_class = result.metadata.get("failure_class")
148
+ if isinstance(raw_failure_class, str):
149
+ failure_class = raw_failure_class
150
+ return build_runlog_entry(
151
+ input_url=item.url,
152
+ platform=item.platform,
153
+ backend=result.backend,
154
+ status=result.status,
155
+ source_type=item.source_type,
156
+ source_level=result.source_level,
157
+ asr_execution=asr_execution_metrics(result.metadata),
158
+ source_scope=result.metadata,
159
+ route_trace={key: result.metadata[key] for key in ("card_id", "route_version", "overlay_applied", "fallback_attempts") if key in result.metadata},
160
+ 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} if item.platform in {"screenshot", "pdf"} else {},
161
+ output_path=output_path,
162
+ package_id=package_id,
163
+ package_operation=package_operation,
164
+ started_at=started_at,
165
+ finished_at=finished_at,
166
+ failure_class=failure_class,
167
+ error_code=result.error_code,
168
+ error_summary=result.error_summary,
169
+ extraction_contract_version=extraction_contract_version_for(item, result),
170
+ components=[
171
+ {
172
+ "id": component.id,
173
+ "kind": component.kind,
174
+ "status": component.status,
175
+ "source_level": component.source_level,
176
+ "backend": component.backend,
177
+ "text_ref": component.text_ref,
178
+ "warnings": component.warnings,
179
+ "terminal_reason": component.terminal_reason,
180
+ "error_code": component.error_code,
181
+ "failure_class": component.failure_class,
182
+ }
183
+ for component in result.normalized_components
184
+ ],
185
+ terminal_gap_count=sum(
186
+ component.status == "unavailable" for component in result.normalized_components
187
+ ),
188
+ )
189
+
190
+
191
+ def normalize_failure_class(value: str | None) -> str | None:
192
+ if value in FAILURE_CLASS_VALUES:
193
+ return value
194
+ return None
195
+
196
+
197
+ def classify_failure(error_code: str | None) -> str | None:
198
+ """Map backend-specific error codes into the shared V1 recovery taxonomy."""
199
+ if not error_code:
200
+ return None
201
+ normalized = str(error_code).lower()
202
+ if any(term in normalized for term in USER_RESOLVABLE_ERROR_TERMS):
203
+ return "user_resolvable"
204
+ if any(term in normalized for term in RUNTIME_TRANSIENT_ERROR_TERMS):
205
+ return "runtime_transient"
206
+ if any(term in normalized for term in SOURCE_UNAVAILABLE_ERROR_TERMS):
207
+ return "source_unavailable"
208
+ return "code_resolvable"