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,278 @@
|
|
|
1
|
+
"""Weixin author-batch coverage report helpers.
|
|
2
|
+
|
|
3
|
+
The report consumes already-sanitized Openmd runlog JSONL rows. It does not
|
|
4
|
+
call the WeChat sidecar, read browser/session state, or persist raw exporter
|
|
5
|
+
responses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
from openmd.core.secrets import sanitize_error_summary, sanitize_url_text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
WEIXIN_FALLBACK_BACKENDS: frozenset[str] = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"wechat-article-exporter.download",
|
|
22
|
+
"opencli.weixin.download",
|
|
23
|
+
"opencli.web.read",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class WeixinAuthorBatchReportResult:
|
|
30
|
+
report: dict[str, Any]
|
|
31
|
+
json_path: Path | None = None
|
|
32
|
+
markdown_path: Path | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_runlog_entries(paths: Iterable[str | Path]) -> list[dict[str, Any]]:
|
|
36
|
+
entries: list[dict[str, Any]] = []
|
|
37
|
+
for path_value in paths:
|
|
38
|
+
path = Path(path_value)
|
|
39
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
40
|
+
for line in handle:
|
|
41
|
+
stripped = line.strip()
|
|
42
|
+
if not stripped:
|
|
43
|
+
continue
|
|
44
|
+
payload = json.loads(stripped)
|
|
45
|
+
if isinstance(payload, dict):
|
|
46
|
+
entries.append(payload)
|
|
47
|
+
return entries
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_weixin_author_batch_report(
|
|
51
|
+
entries: Iterable[dict[str, Any]],
|
|
52
|
+
*,
|
|
53
|
+
title: str = "Weixin author batch coverage report",
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
56
|
+
ignored_rows = 0
|
|
57
|
+
for row in entries:
|
|
58
|
+
if row.get("platform") != "weixin" or row.get("event_type", "item") != "item":
|
|
59
|
+
ignored_rows += 1
|
|
60
|
+
continue
|
|
61
|
+
input_url = _safe_text(row.get("input_url"))
|
|
62
|
+
if not input_url:
|
|
63
|
+
ignored_rows += 1
|
|
64
|
+
continue
|
|
65
|
+
grouped[input_url].append(_sanitize_report_row(row))
|
|
66
|
+
|
|
67
|
+
item_reports = [_item_report(input_url, attempts) for input_url, attempts in grouped.items()]
|
|
68
|
+
item_reports.sort(key=lambda item: item["input_url"])
|
|
69
|
+
|
|
70
|
+
final_status_counts = Counter(item["final_status"] for item in item_reports)
|
|
71
|
+
final_failure_class_counts = Counter(
|
|
72
|
+
item["final_failure_class"] for item in item_reports if item.get("final_failure_class")
|
|
73
|
+
)
|
|
74
|
+
final_error_code_counts = Counter(
|
|
75
|
+
item["final_error_code"] for item in item_reports if item.get("final_error_code")
|
|
76
|
+
)
|
|
77
|
+
final_backend_counts = Counter(item["final_backend"] for item in item_reports if item.get("final_backend"))
|
|
78
|
+
all_error_code_counts: Counter[str] = Counter()
|
|
79
|
+
fallback_backend_counts: Counter[str] = Counter()
|
|
80
|
+
for item in item_reports:
|
|
81
|
+
for attempt_index, attempt in enumerate(item["attempts"]):
|
|
82
|
+
error_code = attempt.get("error_code")
|
|
83
|
+
backend = attempt.get("backend")
|
|
84
|
+
if error_code:
|
|
85
|
+
all_error_code_counts[str(error_code)] += 1
|
|
86
|
+
if attempt_index > 0 and backend in WEIXIN_FALLBACK_BACKENDS:
|
|
87
|
+
fallback_backend_counts[str(backend)] += 1
|
|
88
|
+
|
|
89
|
+
covered_items = [item for item in item_reports if item["covered"]]
|
|
90
|
+
recovered_items = [item for item in item_reports if item["recovered"]]
|
|
91
|
+
missing_items = [item for item in item_reports if item["missing"]]
|
|
92
|
+
fallback_attempt_items = [item for item in item_reports if item["fallback_attempt_count"] > 0]
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"title": title,
|
|
96
|
+
"summary": {
|
|
97
|
+
"total_items": len(item_reports),
|
|
98
|
+
"covered_count": len(covered_items),
|
|
99
|
+
"verified_count": final_status_counts.get("verified", 0),
|
|
100
|
+
"partial_count": final_status_counts.get("partial", 0),
|
|
101
|
+
"failed_count": final_status_counts.get("failed", 0),
|
|
102
|
+
"recovered_count": len(recovered_items),
|
|
103
|
+
"missing_count": len(missing_items),
|
|
104
|
+
"fallback_attempt_item_count": len(fallback_attempt_items),
|
|
105
|
+
"fallback_attempt_count": sum(item["fallback_attempt_count"] for item in item_reports),
|
|
106
|
+
"ignored_rows": ignored_rows,
|
|
107
|
+
},
|
|
108
|
+
"final_status_counts": dict(sorted(final_status_counts.items())),
|
|
109
|
+
"final_failure_class_counts": dict(sorted(final_failure_class_counts.items())),
|
|
110
|
+
"final_error_code_counts": dict(sorted(final_error_code_counts.items())),
|
|
111
|
+
"final_backend_counts": dict(sorted(final_backend_counts.items())),
|
|
112
|
+
"all_attempt_error_code_counts": dict(sorted(all_error_code_counts.items())),
|
|
113
|
+
"fallback_backend_counts": dict(sorted(fallback_backend_counts.items())),
|
|
114
|
+
"recovered_items": [_compact_item(item) for item in recovered_items],
|
|
115
|
+
"missing_items": [_compact_item(item) for item in missing_items],
|
|
116
|
+
"other_failed_items": [
|
|
117
|
+
_compact_item(item)
|
|
118
|
+
for item in item_reports
|
|
119
|
+
if item["final_status"] == "failed" and not item["missing"]
|
|
120
|
+
],
|
|
121
|
+
"items": item_reports,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def write_weixin_author_batch_report(
|
|
126
|
+
entries: Iterable[dict[str, Any]],
|
|
127
|
+
out_dir: str | Path,
|
|
128
|
+
*,
|
|
129
|
+
title: str = "Weixin author batch coverage report",
|
|
130
|
+
write_json: bool = True,
|
|
131
|
+
write_markdown: bool = True,
|
|
132
|
+
) -> WeixinAuthorBatchReportResult:
|
|
133
|
+
report = build_weixin_author_batch_report(entries, title=title)
|
|
134
|
+
output_dir = Path(out_dir)
|
|
135
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
json_path: Path | None = None
|
|
137
|
+
markdown_path: Path | None = None
|
|
138
|
+
if write_json:
|
|
139
|
+
json_path = output_dir / "weixin-author-batch-report.json"
|
|
140
|
+
json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
|
141
|
+
if write_markdown:
|
|
142
|
+
markdown_path = output_dir / "weixin-author-batch-report.md"
|
|
143
|
+
markdown_path.write_text(format_weixin_author_batch_report_markdown(report), encoding="utf-8")
|
|
144
|
+
return WeixinAuthorBatchReportResult(report=report, json_path=json_path, markdown_path=markdown_path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def format_weixin_author_batch_report_markdown(report: dict[str, Any]) -> str:
|
|
148
|
+
summary = report.get("summary", {})
|
|
149
|
+
lines = [
|
|
150
|
+
f"# {report.get('title') or 'Weixin author batch coverage report'}",
|
|
151
|
+
"",
|
|
152
|
+
"## Summary",
|
|
153
|
+
"",
|
|
154
|
+
f"- Total items: {summary.get('total_items', 0)}",
|
|
155
|
+
f"- Covered: {summary.get('covered_count', 0)}",
|
|
156
|
+
f"- Verified: {summary.get('verified_count', 0)}",
|
|
157
|
+
f"- Partial: {summary.get('partial_count', 0)}",
|
|
158
|
+
f"- Failed: {summary.get('failed_count', 0)}",
|
|
159
|
+
f"- Recovered: {summary.get('recovered_count', 0)}",
|
|
160
|
+
f"- Missing/source unavailable: {summary.get('missing_count', 0)}",
|
|
161
|
+
f"- Fallback attempt items: {summary.get('fallback_attempt_item_count', 0)}",
|
|
162
|
+
f"- Fallback attempts: {summary.get('fallback_attempt_count', 0)}",
|
|
163
|
+
"",
|
|
164
|
+
]
|
|
165
|
+
lines.extend(_counter_section("Final failure classes", report.get("final_failure_class_counts", {})))
|
|
166
|
+
lines.extend(_counter_section("Final error codes", report.get("final_error_code_counts", {})))
|
|
167
|
+
lines.extend(_counter_section("Fallback backends", report.get("fallback_backend_counts", {})))
|
|
168
|
+
lines.extend(_items_section("Recovered items", report.get("recovered_items", [])))
|
|
169
|
+
lines.extend(_items_section("Missing items", report.get("missing_items", [])))
|
|
170
|
+
lines.extend(_items_section("Other failed items", report.get("other_failed_items", [])))
|
|
171
|
+
lines.append("")
|
|
172
|
+
return "\n".join(lines)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _item_report(input_url: str, attempts: list[dict[str, Any]]) -> dict[str, Any]:
|
|
176
|
+
final = attempts[-1]
|
|
177
|
+
final_status = _safe_text(final.get("status")) or "failed"
|
|
178
|
+
covered = final_status in {"verified", "partial"}
|
|
179
|
+
final_error_code = _safe_text(final.get("error_code"))
|
|
180
|
+
final_failure_class = _safe_text(final.get("failure_class"))
|
|
181
|
+
had_failed_attempt = any(_safe_text(attempt.get("status")) == "failed" for attempt in attempts[:-1])
|
|
182
|
+
recovered = covered and had_failed_attempt
|
|
183
|
+
missing = (
|
|
184
|
+
final_status == "failed"
|
|
185
|
+
and (
|
|
186
|
+
final_failure_class == "source_unavailable"
|
|
187
|
+
or _is_source_empty_error(final_error_code)
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
fallback_attempt_count = max(0, len(attempts) - 1)
|
|
191
|
+
return {
|
|
192
|
+
"input_url": sanitize_url_text(input_url),
|
|
193
|
+
"covered": covered,
|
|
194
|
+
"recovered": recovered,
|
|
195
|
+
"missing": missing,
|
|
196
|
+
"attempt_count": len(attempts),
|
|
197
|
+
"fallback_attempt_count": fallback_attempt_count,
|
|
198
|
+
"final_backend": _safe_text(final.get("backend")),
|
|
199
|
+
"final_status": final_status,
|
|
200
|
+
"final_source_level": _safe_text(final.get("source_level")),
|
|
201
|
+
"final_failure_class": final_failure_class,
|
|
202
|
+
"final_error_code": final_error_code,
|
|
203
|
+
"final_error_summary": sanitize_error_summary(final.get("error_summary")),
|
|
204
|
+
"output_path": _safe_text(final.get("output_path")),
|
|
205
|
+
"package_id": _safe_text(final.get("package_id")),
|
|
206
|
+
"package_operation": _safe_text(final.get("package_operation")),
|
|
207
|
+
"attempts": attempts,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _sanitize_report_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
212
|
+
return {
|
|
213
|
+
"backend": _safe_text(row.get("backend")),
|
|
214
|
+
"status": _safe_text(row.get("status")),
|
|
215
|
+
"source_level": _safe_text(row.get("source_level")),
|
|
216
|
+
"failure_class": _safe_text(row.get("failure_class")),
|
|
217
|
+
"error_code": _safe_text(row.get("error_code")),
|
|
218
|
+
"error_summary": sanitize_error_summary(row.get("error_summary")),
|
|
219
|
+
"output_path": _safe_text(row.get("output_path")),
|
|
220
|
+
"package_id": _safe_text(row.get("package_id")),
|
|
221
|
+
"package_operation": _safe_text(row.get("package_operation")),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _compact_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
"input_url": item["input_url"],
|
|
228
|
+
"final_backend": item["final_backend"],
|
|
229
|
+
"final_status": item["final_status"],
|
|
230
|
+
"final_failure_class": item["final_failure_class"],
|
|
231
|
+
"final_error_code": item["final_error_code"],
|
|
232
|
+
"attempt_count": item["attempt_count"],
|
|
233
|
+
"fallback_attempt_count": item["fallback_attempt_count"],
|
|
234
|
+
"package_id": item.get("package_id") or None,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _counter_section(title: str, values: dict[str, Any]) -> list[str]:
|
|
239
|
+
lines = [f"## {title}", ""]
|
|
240
|
+
if not values:
|
|
241
|
+
lines.extend(["- None", ""])
|
|
242
|
+
return lines
|
|
243
|
+
for key, value in values.items():
|
|
244
|
+
lines.append(f"- `{key}`: {value}")
|
|
245
|
+
lines.append("")
|
|
246
|
+
return lines
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _items_section(title: str, items: list[dict[str, Any]]) -> list[str]:
|
|
250
|
+
lines = [f"## {title}", ""]
|
|
251
|
+
if not items:
|
|
252
|
+
lines.extend(["- None", ""])
|
|
253
|
+
return lines
|
|
254
|
+
for item in items[:50]:
|
|
255
|
+
lines.append(
|
|
256
|
+
"- "
|
|
257
|
+
f"{item.get('input_url')} | "
|
|
258
|
+
f"backend={item.get('final_backend')} | "
|
|
259
|
+
f"status={item.get('final_status')} | "
|
|
260
|
+
f"failure_class={item.get('final_failure_class')} | "
|
|
261
|
+
f"error={item.get('final_error_code')} | "
|
|
262
|
+
f"attempts={item.get('attempt_count')}"
|
|
263
|
+
)
|
|
264
|
+
if len(items) > 50:
|
|
265
|
+
lines.append(f"- ... {len(items) - 50} more omitted from Markdown view")
|
|
266
|
+
lines.append("")
|
|
267
|
+
return lines
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _safe_text(value: Any) -> str:
|
|
271
|
+
if value is None:
|
|
272
|
+
return ""
|
|
273
|
+
return sanitize_url_text(str(value).strip())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _is_source_empty_error(value: str) -> bool:
|
|
277
|
+
normalized = value.lower()
|
|
278
|
+
return "source_empty" in normalized or "empty_result" in normalized or "not_found" in normalized
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Xiaohongshu adapter declaring the multimodal extraction contract before runtime skip checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from openmd.adapters.base import PlatformAdapter
|
|
10
|
+
from openmd.backends.xiaohongshu import has_required_signed_query, normalize_xiaohongshu_url, xiaohongshu_user_id
|
|
11
|
+
from openmd.backends.xiaohongshu_providers import XIAOHONGSHU_COMPOSITE_BACKEND
|
|
12
|
+
from openmd.core.detector import detect_platform, extract_first_url
|
|
13
|
+
from openmd.core.models import ContentItem, ExtractionComponent, TranscriptResult
|
|
14
|
+
from openmd.core.package_contract import XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION
|
|
15
|
+
from openmd.core.secrets import sanitize_url
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_NOTE_ID_RE = re.compile(r"^[0-9a-f]{24}$", re.I)
|
|
19
|
+
_ALL_COMPONENTS = ["native_text", "image_ocr", "video_transcript"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class XiaohongshuAdapter(PlatformAdapter):
|
|
23
|
+
def __init__(self, platform_id: str = "xiaohongshu", default_backend: str = XIAOHONGSHU_COMPOSITE_BACKEND) -> None:
|
|
24
|
+
self.id = platform_id
|
|
25
|
+
self.default_backend = default_backend
|
|
26
|
+
|
|
27
|
+
def can_handle(self, input_value: str) -> bool:
|
|
28
|
+
return detect_platform(input_value) == "xiaohongshu"
|
|
29
|
+
|
|
30
|
+
def resolve_items(
|
|
31
|
+
self,
|
|
32
|
+
input_value: str,
|
|
33
|
+
*,
|
|
34
|
+
limit: int | None = None,
|
|
35
|
+
all: bool = False,
|
|
36
|
+
) -> list[ContentItem]:
|
|
37
|
+
target = normalize_xiaohongshu_url(extract_first_url(input_value))
|
|
38
|
+
note_id = _note_id_from_url(target)
|
|
39
|
+
is_short_note = _is_xhslink_url(target)
|
|
40
|
+
if note_id or is_short_note:
|
|
41
|
+
content_id = note_id or _stable_id(target)
|
|
42
|
+
profile_id = _profile_id_from_note_url(target)
|
|
43
|
+
raw_meta: dict[str, object] = {
|
|
44
|
+
"permission_scope": "user_accessible",
|
|
45
|
+
"content_id": content_id,
|
|
46
|
+
"media_kind": "mixed",
|
|
47
|
+
"extraction_contract_version": XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
|
|
48
|
+
"required_components": list(_ALL_COMPONENTS),
|
|
49
|
+
}
|
|
50
|
+
if profile_id:
|
|
51
|
+
raw_meta["profile_id"] = profile_id
|
|
52
|
+
if has_required_signed_query(target):
|
|
53
|
+
raw_meta["runtime_note_url"] = target
|
|
54
|
+
if is_short_note:
|
|
55
|
+
raw_meta["runtime_selector"] = target
|
|
56
|
+
raw_meta["identity_unresolved"] = True
|
|
57
|
+
return [
|
|
58
|
+
ContentItem(
|
|
59
|
+
platform=self.id,
|
|
60
|
+
input_type="single",
|
|
61
|
+
url=(
|
|
62
|
+
f"https://www.xiaohongshu.com/discovery/item/{note_id}"
|
|
63
|
+
if note_id
|
|
64
|
+
else sanitize_url(target)
|
|
65
|
+
),
|
|
66
|
+
id=content_id,
|
|
67
|
+
source_type="mixed",
|
|
68
|
+
raw_meta=raw_meta,
|
|
69
|
+
)
|
|
70
|
+
]
|
|
71
|
+
profile_id = xiaohongshu_user_id(target)
|
|
72
|
+
return [
|
|
73
|
+
ContentItem(
|
|
74
|
+
platform=self.id,
|
|
75
|
+
input_type="collection",
|
|
76
|
+
url=sanitize_url(target),
|
|
77
|
+
id=f"user-{profile_id}" if profile_id else _stable_id(target),
|
|
78
|
+
source_type="mixed",
|
|
79
|
+
raw_meta={
|
|
80
|
+
"permission_scope": "user_accessible",
|
|
81
|
+
"profile_id": profile_id,
|
|
82
|
+
"collection_selector": input_value.strip(),
|
|
83
|
+
"discovery_limit": limit,
|
|
84
|
+
"allow_all": all,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def extract_text(
|
|
90
|
+
self,
|
|
91
|
+
item: ContentItem,
|
|
92
|
+
*,
|
|
93
|
+
backend: str | None = None,
|
|
94
|
+
backend_chain: tuple[str, ...] | None = None,
|
|
95
|
+
allow_fallback: bool = True,
|
|
96
|
+
) -> TranscriptResult:
|
|
97
|
+
selected = (backend_chain or (backend or self.default_backend,))[0]
|
|
98
|
+
if selected == "mock":
|
|
99
|
+
return TranscriptResult(
|
|
100
|
+
text="## Native Note Text\n\nMock Xiaohongshu note.\n\n## Image OCR\n\n_Not applicable._\n\n## Video Transcript\n\n_Not applicable._\n",
|
|
101
|
+
status="verified",
|
|
102
|
+
source_level="native_text",
|
|
103
|
+
backend="mock",
|
|
104
|
+
title=item.title or item.id or "Mock Xiaohongshu note",
|
|
105
|
+
extraction_contract_version=XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
|
|
106
|
+
components=[
|
|
107
|
+
ExtractionComponent(
|
|
108
|
+
"native_text", "native_text", "verified", "native_text", "mock",
|
|
109
|
+
text_ref="index.md#native-note-text",
|
|
110
|
+
),
|
|
111
|
+
ExtractionComponent(
|
|
112
|
+
"image_ocr", "image_ocr", "not_applicable", "partial_extraction", "mock",
|
|
113
|
+
text_ref="index.md#image-ocr",
|
|
114
|
+
),
|
|
115
|
+
ExtractionComponent(
|
|
116
|
+
"video_transcript", "video_transcript", "not_applicable", "partial_extraction", "mock",
|
|
117
|
+
text_ref="index.md#video-transcript",
|
|
118
|
+
),
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
return TranscriptResult(
|
|
122
|
+
text="",
|
|
123
|
+
status="failed",
|
|
124
|
+
source_level="partial_extraction",
|
|
125
|
+
backend=selected,
|
|
126
|
+
error_code="backend_requires_runtime_gate",
|
|
127
|
+
error_summary="Enable the browser backend gate before running Xiaohongshu extraction.",
|
|
128
|
+
source_notes=[f"allow_fallback={allow_fallback}"],
|
|
129
|
+
extraction_contract_version=XIAOHONGSHU_EXTRACTION_CONTRACT_VERSION,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_adapter(platform_id: str, _default_backend: str) -> PlatformAdapter:
|
|
134
|
+
return XiaohongshuAdapter(platform_id, XIAOHONGSHU_COMPOSITE_BACKEND)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _note_id_from_url(value: str) -> str | None:
|
|
138
|
+
parsed = urlparse(value)
|
|
139
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
140
|
+
candidate = parts[-1] if parts else ""
|
|
141
|
+
return candidate.lower() if _NOTE_ID_RE.fullmatch(candidate) else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _profile_id_from_note_url(value: str) -> str | None:
|
|
145
|
+
parsed = urlparse(value)
|
|
146
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
147
|
+
if len(parts) >= 4 and parts[:2] == ["user", "profile"]:
|
|
148
|
+
return parts[2]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_xhslink_url(value: str) -> bool:
|
|
153
|
+
host = urlparse(value).netloc.lower()
|
|
154
|
+
return host == "xhslink.com" or host.endswith(".xhslink.com")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _stable_id(value: str) -> str:
|
|
158
|
+
return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()[:12]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Backend wrapper interfaces."""
|
openmd/backends/base.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Backend framework primitives.
|
|
2
|
+
|
|
3
|
+
The V0 implementation only wires skeleton providers, but these dataclasses keep
|
|
4
|
+
the code aligned with docs/06-backend-framework.md: backends describe their
|
|
5
|
+
capabilities, report dependency health, and extract through one normalized
|
|
6
|
+
contract owned by Openmd.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Literal, Protocol
|
|
13
|
+
|
|
14
|
+
from openmd.core.models import (
|
|
15
|
+
ContentItem,
|
|
16
|
+
InputType,
|
|
17
|
+
SourceLevel,
|
|
18
|
+
SourceType,
|
|
19
|
+
TranscriptResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
BackendLayer = str
|
|
24
|
+
DiscoveryStatus = Literal["ok", "failed"]
|
|
25
|
+
IdentityResolutionStatus = Literal["resolved", "failed"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class BackendCapability:
|
|
30
|
+
platforms: tuple[str, ...]
|
|
31
|
+
source_types: tuple[SourceType, ...]
|
|
32
|
+
input_types: tuple[InputType, ...]
|
|
33
|
+
source_levels: tuple[SourceLevel, ...]
|
|
34
|
+
supports_batch: bool = False
|
|
35
|
+
supports_collection: bool = False
|
|
36
|
+
requires_network: bool = True
|
|
37
|
+
requires_credentials: bool = False
|
|
38
|
+
optional_config: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class BackendDescriptor:
|
|
43
|
+
name: str
|
|
44
|
+
layer: BackendLayer
|
|
45
|
+
capabilities: tuple[BackendCapability, ...]
|
|
46
|
+
description: str = ""
|
|
47
|
+
provider: str | None = None
|
|
48
|
+
is_fallback: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class BackendHealth:
|
|
53
|
+
ok: bool
|
|
54
|
+
dependencies: dict[str, str] = field(default_factory=dict)
|
|
55
|
+
warnings: tuple[str, ...] = ()
|
|
56
|
+
error_code: str | None = None
|
|
57
|
+
error_summary: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class BackendExtractRequest:
|
|
62
|
+
item: ContentItem
|
|
63
|
+
backend_chain: tuple[str, ...]
|
|
64
|
+
allow_fallback: bool = True
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def primary_backend(self) -> str:
|
|
68
|
+
return self.backend_chain[0] if self.backend_chain else "mock"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ItemIdentityResolution:
|
|
73
|
+
item: ContentItem
|
|
74
|
+
status: IdentityResolutionStatus
|
|
75
|
+
provider: str
|
|
76
|
+
error_code: str | None = None
|
|
77
|
+
error_summary: str | None = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def ok(self) -> bool:
|
|
81
|
+
return self.status == "resolved" and self.error_code is None
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def success(cls, item: ContentItem, *, provider: str) -> "ItemIdentityResolution":
|
|
85
|
+
return cls(item=item, status="resolved", provider=provider)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def failure(
|
|
89
|
+
cls,
|
|
90
|
+
item: ContentItem,
|
|
91
|
+
*,
|
|
92
|
+
provider: str,
|
|
93
|
+
error_code: str,
|
|
94
|
+
error_summary: str,
|
|
95
|
+
) -> "ItemIdentityResolution":
|
|
96
|
+
return cls(
|
|
97
|
+
item=item,
|
|
98
|
+
status="failed",
|
|
99
|
+
provider=provider,
|
|
100
|
+
error_code=error_code,
|
|
101
|
+
error_summary=error_summary,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class DiscoveryRequest:
|
|
107
|
+
"""Request to resolve a collection/profile/playlist into extractable items."""
|
|
108
|
+
|
|
109
|
+
input_url: str
|
|
110
|
+
platform: str
|
|
111
|
+
limit: int | None = None
|
|
112
|
+
allow_all: bool = False
|
|
113
|
+
raw_meta: dict[str, Any] = field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class DiscoveryResult:
|
|
118
|
+
"""Structured collection discovery result.
|
|
119
|
+
|
|
120
|
+
Discovery failures are expected platform/backend outcomes and should be
|
|
121
|
+
returned to the CLI instead of raised as command-level exceptions.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
provider: str
|
|
125
|
+
status: DiscoveryStatus
|
|
126
|
+
items: list[ContentItem] = field(default_factory=list)
|
|
127
|
+
source_notes: list[str] = field(default_factory=list)
|
|
128
|
+
error_code: str | None = None
|
|
129
|
+
error_summary: str | None = None
|
|
130
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def ok(self) -> bool:
|
|
134
|
+
return self.status == "ok" and self.error_code is None
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def success(
|
|
138
|
+
cls,
|
|
139
|
+
*,
|
|
140
|
+
provider: str,
|
|
141
|
+
items: list[ContentItem],
|
|
142
|
+
source_notes: list[str] | None = None,
|
|
143
|
+
metadata: dict[str, Any] | None = None,
|
|
144
|
+
) -> "DiscoveryResult":
|
|
145
|
+
return cls(
|
|
146
|
+
provider=provider,
|
|
147
|
+
status="ok",
|
|
148
|
+
items=items,
|
|
149
|
+
source_notes=source_notes or [],
|
|
150
|
+
metadata=metadata or {},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def failure(
|
|
155
|
+
cls,
|
|
156
|
+
*,
|
|
157
|
+
provider: str,
|
|
158
|
+
error_code: str,
|
|
159
|
+
error_summary: str,
|
|
160
|
+
source_notes: list[str] | None = None,
|
|
161
|
+
metadata: dict[str, Any] | None = None,
|
|
162
|
+
) -> "DiscoveryResult":
|
|
163
|
+
return cls(
|
|
164
|
+
provider=provider,
|
|
165
|
+
status="failed",
|
|
166
|
+
error_code=error_code,
|
|
167
|
+
error_summary=error_summary,
|
|
168
|
+
source_notes=source_notes or [],
|
|
169
|
+
metadata=metadata or {},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class BackendProvider(Protocol):
|
|
174
|
+
name: str
|
|
175
|
+
|
|
176
|
+
def descriptor(self) -> BackendDescriptor:
|
|
177
|
+
...
|
|
178
|
+
|
|
179
|
+
def health(self) -> BackendHealth:
|
|
180
|
+
...
|
|
181
|
+
|
|
182
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
183
|
+
...
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class DiscoveryProvider(Protocol):
|
|
187
|
+
name: str
|
|
188
|
+
|
|
189
|
+
def discover(self, request: DiscoveryRequest) -> DiscoveryResult:
|
|
190
|
+
...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class BackendCommandResult:
|
|
195
|
+
name: str
|
|
196
|
+
command: tuple[str, ...]
|
|
197
|
+
exit_code: int
|
|
198
|
+
stdout_summary: str = ""
|
|
199
|
+
stderr_summary: str = ""
|
|
200
|
+
error_summary: str | None = None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def success(self) -> bool:
|
|
204
|
+
return self.exit_code == 0
|