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