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,861 @@
|
|
|
1
|
+
"""Feishu/Lark document backend providers.
|
|
2
|
+
|
|
3
|
+
The providers in this module are registered only through the runtime extension
|
|
4
|
+
hook in ``openmd.backends.registry``. The lark-cli based providers stay behind
|
|
5
|
+
``--enable-network-backends`` because they may perform authenticated network
|
|
6
|
+
requests. The manual fallback is local-only and never invokes external tools.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import inspect
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
from openmd.backends.base import (
|
|
23
|
+
BackendCapability,
|
|
24
|
+
BackendDescriptor,
|
|
25
|
+
BackendExtractRequest,
|
|
26
|
+
BackendHealth,
|
|
27
|
+
)
|
|
28
|
+
from openmd.backends.executor import BackendProviderRegistry
|
|
29
|
+
from openmd.backends.registry import BackendRuntimeOptions
|
|
30
|
+
from openmd.core.models import ContentItem, TranscriptResult
|
|
31
|
+
from openmd.core.secrets import sanitize_error_summary
|
|
32
|
+
from openmd.runtime.tempdirs import owned_temporary_directory
|
|
33
|
+
from openmd.runtime.interpreters import run_interruptibly
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
LARK_CLI_DOCS_FETCH_BACKEND = "lark-cli.docs.fetch"
|
|
37
|
+
LARK_CLI_DRIVE_EXPORT_MARKDOWN_BACKEND = "lark-cli.drive.export-markdown"
|
|
38
|
+
LARK_CLI_MARKDOWN_FETCH_BACKEND = "lark-cli.markdown.fetch"
|
|
39
|
+
MANUAL_MARKDOWN_EXPORT_BACKEND = "manual.markdown-export"
|
|
40
|
+
|
|
41
|
+
SUPPORTED_DOC_TYPES: frozenset[str] = frozenset({"doc", "docx"})
|
|
42
|
+
UNSUPPORTED_V0_OBJECT_TYPES: frozenset[str] = frozenset(
|
|
43
|
+
{"bitable", "sheet", "sheets", "slides", "mindnote", "folder", "file"}
|
|
44
|
+
)
|
|
45
|
+
DEFAULT_TIMEOUT_SECONDS = 120
|
|
46
|
+
DEFAULT_MIN_MARKDOWN_CHARS = 1
|
|
47
|
+
|
|
48
|
+
CommandRunner = Callable[..., tuple[int, str, str]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class FeishuObjectInfo:
|
|
53
|
+
object_type: str | None = None
|
|
54
|
+
title: str | None = None
|
|
55
|
+
token: str | None = None
|
|
56
|
+
file_name: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def register_backend_providers(
|
|
60
|
+
registry: BackendProviderRegistry,
|
|
61
|
+
options: BackendRuntimeOptions,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Register Feishu providers through explicit runtime gates."""
|
|
64
|
+
requested = options.backend_override
|
|
65
|
+
if options.enable_network_backends and requested in {None, LARK_CLI_DOCS_FETCH_BACKEND}:
|
|
66
|
+
registry.register(LARK_CLI_DOCS_FETCH_BACKEND, LarkCliDocsFetchProvider())
|
|
67
|
+
if options.enable_network_backends and requested in {None, LARK_CLI_DRIVE_EXPORT_MARKDOWN_BACKEND}:
|
|
68
|
+
registry.register(
|
|
69
|
+
LARK_CLI_DRIVE_EXPORT_MARKDOWN_BACKEND,
|
|
70
|
+
LarkCliDriveExportMarkdownProvider(),
|
|
71
|
+
)
|
|
72
|
+
if options.enable_network_backends and requested in {None, LARK_CLI_MARKDOWN_FETCH_BACKEND}:
|
|
73
|
+
registry.register(LARK_CLI_MARKDOWN_FETCH_BACKEND, LarkCliMarkdownFetchProvider())
|
|
74
|
+
|
|
75
|
+
if requested in {None, MANUAL_MARKDOWN_EXPORT_BACKEND}:
|
|
76
|
+
registry.register(MANUAL_MARKDOWN_EXPORT_BACKEND, ManualMarkdownExportProvider())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_lark_docs_fetch_args(doc: str) -> list[str]:
|
|
80
|
+
return [
|
|
81
|
+
"lark-cli",
|
|
82
|
+
"docs",
|
|
83
|
+
"+fetch",
|
|
84
|
+
"--api-version",
|
|
85
|
+
"v2",
|
|
86
|
+
"--doc",
|
|
87
|
+
doc,
|
|
88
|
+
"--doc-format",
|
|
89
|
+
"markdown",
|
|
90
|
+
"--detail",
|
|
91
|
+
"simple",
|
|
92
|
+
"--format",
|
|
93
|
+
"json",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_lark_drive_inspect_args(input_value: str) -> list[str]:
|
|
98
|
+
return ["lark-cli", "drive", "+inspect", "--url", input_value, "--format", "json"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_lark_drive_export_markdown_args(
|
|
102
|
+
*,
|
|
103
|
+
token: str,
|
|
104
|
+
doc_type: str,
|
|
105
|
+
output_dir: str,
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
return [
|
|
108
|
+
"lark-cli",
|
|
109
|
+
"drive",
|
|
110
|
+
"+export",
|
|
111
|
+
"--doc-type",
|
|
112
|
+
doc_type,
|
|
113
|
+
"--file-extension",
|
|
114
|
+
"markdown",
|
|
115
|
+
"--token",
|
|
116
|
+
token,
|
|
117
|
+
"--output-dir",
|
|
118
|
+
output_dir,
|
|
119
|
+
"--overwrite",
|
|
120
|
+
"--format",
|
|
121
|
+
"json",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_lark_markdown_fetch_args(file_token: str) -> list[str]:
|
|
126
|
+
return ["lark-cli", "markdown", "+fetch", "--file-token", file_token, "--format", "json"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class LarkCliDocsFetchProvider:
|
|
130
|
+
"""Fetch Feishu doc/docx native Markdown through ``lark-cli docs +fetch``."""
|
|
131
|
+
|
|
132
|
+
name = LARK_CLI_DOCS_FETCH_BACKEND
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
runner: CommandRunner | None = None,
|
|
138
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
139
|
+
min_markdown_chars: int = DEFAULT_MIN_MARKDOWN_CHARS,
|
|
140
|
+
) -> None:
|
|
141
|
+
self._runner = runner or _run_command
|
|
142
|
+
self.timeout = timeout
|
|
143
|
+
self.min_markdown_chars = max(0, min_markdown_chars)
|
|
144
|
+
|
|
145
|
+
def descriptor(self) -> BackendDescriptor:
|
|
146
|
+
capability = BackendCapability(
|
|
147
|
+
platforms=("feishu",),
|
|
148
|
+
source_types=("document",),
|
|
149
|
+
input_types=("single", "batch"),
|
|
150
|
+
source_levels=("native_text", "partial_extraction"),
|
|
151
|
+
requires_network=True,
|
|
152
|
+
requires_credentials=True,
|
|
153
|
+
optional_config=("lark-cli", "lark-cli auth login"),
|
|
154
|
+
)
|
|
155
|
+
return BackendDescriptor(
|
|
156
|
+
name=self.name,
|
|
157
|
+
layer="L1 specialized extractor",
|
|
158
|
+
capabilities=(capability,),
|
|
159
|
+
description="Fetch Feishu doc/docx content as native Markdown through lark-cli docs +fetch.",
|
|
160
|
+
provider="lark-cli",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def health(self) -> BackendHealth:
|
|
164
|
+
return _lark_cli_health(self._runner)
|
|
165
|
+
|
|
166
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
167
|
+
item = request.item
|
|
168
|
+
if item.platform != "feishu":
|
|
169
|
+
return _failed(
|
|
170
|
+
self.name,
|
|
171
|
+
"unsupported_platform",
|
|
172
|
+
f"Feishu docs provider only supports feishu, got {item.platform}.",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
doc_input = _preferred_doc_input(item)
|
|
176
|
+
title = item.title
|
|
177
|
+
object_type: str | None = None
|
|
178
|
+
inspected = False
|
|
179
|
+
|
|
180
|
+
if _should_inspect_before_docs_fetch(doc_input):
|
|
181
|
+
inspected = True
|
|
182
|
+
inspect_result = _inspect_feishu_object(
|
|
183
|
+
doc_input,
|
|
184
|
+
runner=self._runner,
|
|
185
|
+
timeout=self.timeout,
|
|
186
|
+
backend=self.name,
|
|
187
|
+
)
|
|
188
|
+
if isinstance(inspect_result, TranscriptResult):
|
|
189
|
+
return inspect_result
|
|
190
|
+
object_type = _normalize_object_type(inspect_result.object_type)
|
|
191
|
+
title = inspect_result.title or title
|
|
192
|
+
if object_type not in SUPPORTED_DOC_TYPES:
|
|
193
|
+
return _unsupported_feishu_object(self.name, object_type, title=title)
|
|
194
|
+
doc_input = inspect_result.token or doc_input
|
|
195
|
+
|
|
196
|
+
exit_code, stdout, stderr = self._runner(
|
|
197
|
+
build_lark_docs_fetch_args(doc_input),
|
|
198
|
+
timeout=self.timeout,
|
|
199
|
+
)
|
|
200
|
+
if exit_code != 0:
|
|
201
|
+
return _command_failed_result(
|
|
202
|
+
self.name,
|
|
203
|
+
exit_code,
|
|
204
|
+
stdout,
|
|
205
|
+
stderr,
|
|
206
|
+
action="lark-cli docs +fetch",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
payload = _loads_json(stdout)
|
|
210
|
+
if payload is None:
|
|
211
|
+
return _failed(self.name, "backend_invalid_json", "lark-cli docs +fetch returned invalid JSON.")
|
|
212
|
+
if _payload_is_failed(payload):
|
|
213
|
+
return _failed(self.name, "lark_cli_docs_fetch_failed", "lark-cli docs +fetch returned ok=false.")
|
|
214
|
+
|
|
215
|
+
text = _extract_content(payload).strip()
|
|
216
|
+
if len("".join(text.split())) < self.min_markdown_chars:
|
|
217
|
+
return _failed(self.name, "empty_result", "lark-cli docs +fetch returned no Markdown content.")
|
|
218
|
+
|
|
219
|
+
return TranscriptResult(
|
|
220
|
+
text=text.rstrip() + "\n",
|
|
221
|
+
status="verified",
|
|
222
|
+
source_level="native_text",
|
|
223
|
+
backend=self.name,
|
|
224
|
+
title=title or _extract_title(payload),
|
|
225
|
+
author=item.author,
|
|
226
|
+
published_at=item.published_at,
|
|
227
|
+
source_notes=[
|
|
228
|
+
"Feishu native Markdown fetched through lark-cli docs +fetch.",
|
|
229
|
+
"No cookies, tokens, or raw API response were persisted by Openmd.",
|
|
230
|
+
],
|
|
231
|
+
metadata={
|
|
232
|
+
"strategy": "docs_fetch",
|
|
233
|
+
"inspected": inspected,
|
|
234
|
+
"object_type": object_type,
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class LarkCliDriveExportMarkdownProvider:
|
|
240
|
+
"""Export Feishu doc/docx to a temporary Markdown file through lark-cli."""
|
|
241
|
+
|
|
242
|
+
name = LARK_CLI_DRIVE_EXPORT_MARKDOWN_BACKEND
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
runner: CommandRunner | None = None,
|
|
248
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
249
|
+
min_markdown_chars: int = DEFAULT_MIN_MARKDOWN_CHARS,
|
|
250
|
+
) -> None:
|
|
251
|
+
self._runner = runner or _run_command
|
|
252
|
+
self.timeout = timeout
|
|
253
|
+
self.min_markdown_chars = max(0, min_markdown_chars)
|
|
254
|
+
|
|
255
|
+
def descriptor(self) -> BackendDescriptor:
|
|
256
|
+
capability = BackendCapability(
|
|
257
|
+
platforms=("feishu",),
|
|
258
|
+
source_types=("document",),
|
|
259
|
+
input_types=("single", "batch"),
|
|
260
|
+
source_levels=("native_text", "partial_extraction"),
|
|
261
|
+
requires_network=True,
|
|
262
|
+
requires_credentials=True,
|
|
263
|
+
optional_config=("lark-cli", "temporary export directory"),
|
|
264
|
+
)
|
|
265
|
+
return BackendDescriptor(
|
|
266
|
+
name=self.name,
|
|
267
|
+
layer="L1 specialized extractor fallback",
|
|
268
|
+
capabilities=(capability,),
|
|
269
|
+
description="Fallback export of Feishu doc/docx to Markdown through lark-cli drive +export.",
|
|
270
|
+
provider="lark-cli",
|
|
271
|
+
is_fallback=True,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def health(self) -> BackendHealth:
|
|
275
|
+
return _lark_cli_health(self._runner)
|
|
276
|
+
|
|
277
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
278
|
+
item = request.item
|
|
279
|
+
if item.platform != "feishu":
|
|
280
|
+
return _failed(
|
|
281
|
+
self.name,
|
|
282
|
+
"unsupported_platform",
|
|
283
|
+
f"Feishu drive export provider only supports feishu, got {item.platform}.",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
inspect_result = _inspect_feishu_object(
|
|
287
|
+
_preferred_doc_input(item),
|
|
288
|
+
runner=self._runner,
|
|
289
|
+
timeout=self.timeout,
|
|
290
|
+
backend=self.name,
|
|
291
|
+
fallback_info=_info_from_item(item),
|
|
292
|
+
)
|
|
293
|
+
if isinstance(inspect_result, TranscriptResult):
|
|
294
|
+
return inspect_result
|
|
295
|
+
|
|
296
|
+
object_type = _normalize_object_type(inspect_result.object_type)
|
|
297
|
+
if object_type not in SUPPORTED_DOC_TYPES:
|
|
298
|
+
return _unsupported_feishu_object(self.name, object_type, title=inspect_result.title or item.title)
|
|
299
|
+
if not inspect_result.token:
|
|
300
|
+
return _failed(self.name, "invalid_input", "Could not resolve a Feishu doc/docx token for export.")
|
|
301
|
+
|
|
302
|
+
with owned_temporary_directory(prefix="openmd-feishu-") as tmpdir:
|
|
303
|
+
exit_code, stdout, stderr = _run_command_with_optional_cwd(
|
|
304
|
+
self._runner,
|
|
305
|
+
build_lark_drive_export_markdown_args(
|
|
306
|
+
token=inspect_result.token,
|
|
307
|
+
doc_type=object_type,
|
|
308
|
+
output_dir=".",
|
|
309
|
+
),
|
|
310
|
+
timeout=self.timeout,
|
|
311
|
+
cwd=tmpdir,
|
|
312
|
+
)
|
|
313
|
+
if exit_code != 0:
|
|
314
|
+
return _command_failed_result(
|
|
315
|
+
self.name,
|
|
316
|
+
exit_code,
|
|
317
|
+
stdout,
|
|
318
|
+
stderr,
|
|
319
|
+
action="lark-cli drive +export",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
payload = _loads_json(stdout)
|
|
323
|
+
text = _read_exported_markdown(Path(tmpdir), payload).strip()
|
|
324
|
+
if len("".join(text.split())) < self.min_markdown_chars:
|
|
325
|
+
return _failed(
|
|
326
|
+
self.name,
|
|
327
|
+
"empty_result",
|
|
328
|
+
"lark-cli drive +export did not produce a usable Markdown file.",
|
|
329
|
+
)
|
|
330
|
+
result = TranscriptResult(
|
|
331
|
+
text=text.rstrip() + "\n",
|
|
332
|
+
status="verified",
|
|
333
|
+
source_level="native_text",
|
|
334
|
+
backend=self.name,
|
|
335
|
+
title=inspect_result.title or item.title,
|
|
336
|
+
author=item.author,
|
|
337
|
+
published_at=item.published_at,
|
|
338
|
+
source_notes=[
|
|
339
|
+
"Feishu doc/docx was exported to Markdown through lark-cli drive +export.",
|
|
340
|
+
"Openmd read the temporary Markdown file and deleted the temporary export directory.",
|
|
341
|
+
"No cookies, tokens, raw command streams, or raw API response were persisted by Openmd.",
|
|
342
|
+
],
|
|
343
|
+
metadata={"strategy": "drive_export_markdown", "object_type": object_type},
|
|
344
|
+
)
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class LarkCliMarkdownFetchProvider:
|
|
349
|
+
"""Fetch Drive-native Markdown files through ``lark-cli markdown +fetch``."""
|
|
350
|
+
|
|
351
|
+
name = LARK_CLI_MARKDOWN_FETCH_BACKEND
|
|
352
|
+
|
|
353
|
+
def __init__(
|
|
354
|
+
self,
|
|
355
|
+
*,
|
|
356
|
+
runner: CommandRunner | None = None,
|
|
357
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
358
|
+
min_markdown_chars: int = DEFAULT_MIN_MARKDOWN_CHARS,
|
|
359
|
+
) -> None:
|
|
360
|
+
self._runner = runner or _run_command
|
|
361
|
+
self.timeout = timeout
|
|
362
|
+
self.min_markdown_chars = max(0, min_markdown_chars)
|
|
363
|
+
|
|
364
|
+
def descriptor(self) -> BackendDescriptor:
|
|
365
|
+
capability = BackendCapability(
|
|
366
|
+
platforms=("feishu",),
|
|
367
|
+
source_types=("document",),
|
|
368
|
+
input_types=("single", "batch"),
|
|
369
|
+
source_levels=("native_text", "partial_extraction"),
|
|
370
|
+
requires_network=True,
|
|
371
|
+
requires_credentials=True,
|
|
372
|
+
optional_config=("lark-cli", "Drive-native Markdown file token"),
|
|
373
|
+
)
|
|
374
|
+
return BackendDescriptor(
|
|
375
|
+
name=self.name,
|
|
376
|
+
layer="L1 specialized extractor fallback",
|
|
377
|
+
capabilities=(capability,),
|
|
378
|
+
description="Fetch Drive-native Markdown file content through lark-cli markdown +fetch.",
|
|
379
|
+
provider="lark-cli",
|
|
380
|
+
is_fallback=True,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def health(self) -> BackendHealth:
|
|
384
|
+
return _lark_cli_health(self._runner)
|
|
385
|
+
|
|
386
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
387
|
+
item = request.item
|
|
388
|
+
if item.platform != "feishu":
|
|
389
|
+
return _failed(
|
|
390
|
+
self.name,
|
|
391
|
+
"unsupported_platform",
|
|
392
|
+
f"Feishu Markdown provider only supports feishu, got {item.platform}.",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
token = _markdown_token_candidate(item)
|
|
396
|
+
title = item.title
|
|
397
|
+
if token is None and _looks_like_feishu_url(item.url):
|
|
398
|
+
inspect_result = _inspect_feishu_object(
|
|
399
|
+
item.url,
|
|
400
|
+
runner=self._runner,
|
|
401
|
+
timeout=self.timeout,
|
|
402
|
+
backend=self.name,
|
|
403
|
+
)
|
|
404
|
+
if isinstance(inspect_result, TranscriptResult):
|
|
405
|
+
return inspect_result
|
|
406
|
+
object_type = _normalize_object_type(inspect_result.object_type)
|
|
407
|
+
title = inspect_result.title or inspect_result.file_name or title
|
|
408
|
+
if object_type != "file" or not _is_markdown_file_name(title):
|
|
409
|
+
return _unsupported_feishu_object(self.name, object_type, title=title)
|
|
410
|
+
token = inspect_result.token
|
|
411
|
+
|
|
412
|
+
if token is None:
|
|
413
|
+
return _failed(
|
|
414
|
+
self.name,
|
|
415
|
+
"invalid_input",
|
|
416
|
+
"Expected a Drive-native Markdown file token or inspectable Feishu Markdown file URL.",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
exit_code, stdout, stderr = self._runner(build_lark_markdown_fetch_args(token), timeout=self.timeout)
|
|
420
|
+
if exit_code != 0:
|
|
421
|
+
return _command_failed_result(
|
|
422
|
+
self.name,
|
|
423
|
+
exit_code,
|
|
424
|
+
stdout,
|
|
425
|
+
stderr,
|
|
426
|
+
action="lark-cli markdown +fetch",
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
payload = _loads_json(stdout)
|
|
430
|
+
if payload is None:
|
|
431
|
+
return _failed(self.name, "backend_invalid_json", "lark-cli markdown +fetch returned invalid JSON.")
|
|
432
|
+
if _payload_is_failed(payload):
|
|
433
|
+
return _failed(self.name, "lark_cli_markdown_fetch_failed", "lark-cli markdown +fetch returned ok=false.")
|
|
434
|
+
|
|
435
|
+
text = _extract_content(payload).strip()
|
|
436
|
+
if len("".join(text.split())) < self.min_markdown_chars:
|
|
437
|
+
return _failed(self.name, "empty_result", "lark-cli markdown +fetch returned no Markdown content.")
|
|
438
|
+
|
|
439
|
+
return TranscriptResult(
|
|
440
|
+
text=text.rstrip() + "\n",
|
|
441
|
+
status="verified",
|
|
442
|
+
source_level="native_text",
|
|
443
|
+
backend=self.name,
|
|
444
|
+
title=title or _extract_title(payload),
|
|
445
|
+
author=item.author,
|
|
446
|
+
published_at=item.published_at,
|
|
447
|
+
source_notes=[
|
|
448
|
+
"Drive-native Markdown fetched through lark-cli markdown +fetch.",
|
|
449
|
+
"No cookies, tokens, or raw API response were persisted by Openmd.",
|
|
450
|
+
],
|
|
451
|
+
metadata={"strategy": "markdown_fetch"},
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class ManualMarkdownExportProvider:
|
|
456
|
+
"""Local-only guidance for manually exported Feishu Markdown."""
|
|
457
|
+
|
|
458
|
+
name = MANUAL_MARKDOWN_EXPORT_BACKEND
|
|
459
|
+
|
|
460
|
+
def descriptor(self) -> BackendDescriptor:
|
|
461
|
+
capability = BackendCapability(
|
|
462
|
+
platforms=("feishu",),
|
|
463
|
+
source_types=("document",),
|
|
464
|
+
input_types=("single", "batch"),
|
|
465
|
+
source_levels=("partial_extraction",),
|
|
466
|
+
requires_network=False,
|
|
467
|
+
requires_credentials=False,
|
|
468
|
+
)
|
|
469
|
+
return BackendDescriptor(
|
|
470
|
+
name=self.name,
|
|
471
|
+
layer="manual fallback",
|
|
472
|
+
capabilities=(capability,),
|
|
473
|
+
description="Explain that the user can manually export Feishu Markdown and ingest it separately.",
|
|
474
|
+
provider="openmd.manual",
|
|
475
|
+
is_fallback=True,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def health(self) -> BackendHealth:
|
|
479
|
+
return BackendHealth(ok=True, dependencies={"manual": "available"})
|
|
480
|
+
|
|
481
|
+
def extract(self, request: BackendExtractRequest) -> TranscriptResult:
|
|
482
|
+
return TranscriptResult(
|
|
483
|
+
text="",
|
|
484
|
+
status="failed",
|
|
485
|
+
source_level="partial_extraction",
|
|
486
|
+
backend=self.name,
|
|
487
|
+
source_notes=[
|
|
488
|
+
"Manual Feishu Markdown export is required for this input.",
|
|
489
|
+
"Export or download Markdown from Feishu, then ingest the local Markdown artifact in a separate workflow.",
|
|
490
|
+
"No network, browser automation, cloud API, or lark-cli command was attempted by this provider.",
|
|
491
|
+
],
|
|
492
|
+
error_code="manual_markdown_export_required",
|
|
493
|
+
error_summary="Manual Markdown export is required for this Feishu input.",
|
|
494
|
+
metadata={"strategy": "manual_markdown_export"},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _run_command(
|
|
499
|
+
args: list[str],
|
|
500
|
+
*,
|
|
501
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
502
|
+
cwd: str | Path | None = None,
|
|
503
|
+
) -> tuple[int, str, str]:
|
|
504
|
+
actual_args = list(args)
|
|
505
|
+
if actual_args and actual_args[0] == "lark-cli":
|
|
506
|
+
actual_args[0] = _resolve_lark_cli_executable()
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
completed = run_interruptibly(
|
|
510
|
+
actual_args,
|
|
511
|
+
capture_output=True,
|
|
512
|
+
text=True,
|
|
513
|
+
encoding="utf-8",
|
|
514
|
+
errors="replace",
|
|
515
|
+
timeout=timeout,
|
|
516
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
517
|
+
check=False,
|
|
518
|
+
)
|
|
519
|
+
except FileNotFoundError:
|
|
520
|
+
return 127, "", "lark-cli executable is not available on PATH."
|
|
521
|
+
except subprocess.TimeoutExpired:
|
|
522
|
+
return 124, "", f"lark-cli command timed out after {timeout} seconds."
|
|
523
|
+
return completed.returncode, completed.stdout, completed.stderr
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _resolve_lark_cli_executable() -> str:
|
|
527
|
+
return shutil.which("lark-cli.cmd") or shutil.which("lark-cli") or "lark-cli"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _lark_cli_health(runner: CommandRunner) -> BackendHealth:
|
|
531
|
+
if runner is not _run_command:
|
|
532
|
+
return BackendHealth(ok=True, dependencies={"lark-cli": "injected"})
|
|
533
|
+
if shutil.which("lark-cli") is None and shutil.which("lark-cli.cmd") is None:
|
|
534
|
+
return BackendHealth(
|
|
535
|
+
ok=False,
|
|
536
|
+
dependencies={"lark-cli": "missing"},
|
|
537
|
+
error_code="backend_dependency_missing",
|
|
538
|
+
error_summary="lark-cli executable is not available on PATH.",
|
|
539
|
+
)
|
|
540
|
+
return BackendHealth(ok=True, dependencies={"lark-cli": "available"})
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _runner_accepts_cwd(runner: CommandRunner) -> bool:
|
|
544
|
+
try:
|
|
545
|
+
signature = inspect.signature(runner)
|
|
546
|
+
except (TypeError, ValueError):
|
|
547
|
+
return False
|
|
548
|
+
return any(
|
|
549
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD or name == "cwd"
|
|
550
|
+
for name, parameter in signature.parameters.items()
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _run_command_with_optional_cwd(
|
|
555
|
+
runner: CommandRunner,
|
|
556
|
+
args: list[str],
|
|
557
|
+
*,
|
|
558
|
+
timeout: int,
|
|
559
|
+
cwd: str | Path | None,
|
|
560
|
+
) -> tuple[int, str, str]:
|
|
561
|
+
if cwd is not None and _runner_accepts_cwd(runner):
|
|
562
|
+
return runner(args, timeout=timeout, cwd=cwd)
|
|
563
|
+
return runner(args, timeout=timeout)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _preferred_doc_input(item: ContentItem) -> str:
|
|
567
|
+
raw_token = item.raw_meta.get("token") or item.raw_meta.get("doc_token") or item.raw_meta.get("file_token")
|
|
568
|
+
return str(raw_token or item.id or item.url)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _markdown_token_candidate(item: ContentItem) -> str | None:
|
|
572
|
+
raw_token = item.raw_meta.get("file_token") or item.raw_meta.get("markdown_file_token") or item.raw_meta.get("token")
|
|
573
|
+
if raw_token:
|
|
574
|
+
return str(raw_token)
|
|
575
|
+
if item.id:
|
|
576
|
+
return item.id
|
|
577
|
+
if not _looks_like_feishu_url(item.url) and "/" not in item.url and ":" not in item.url:
|
|
578
|
+
return item.url.strip() or None
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _should_inspect_before_docs_fetch(input_value: str) -> bool:
|
|
583
|
+
if not _looks_like_feishu_url(input_value):
|
|
584
|
+
return False
|
|
585
|
+
parsed = urlparse(input_value)
|
|
586
|
+
path = parsed.path.lower()
|
|
587
|
+
if "/wiki/" in path:
|
|
588
|
+
return True
|
|
589
|
+
first_part = next((part for part in path.split("/") if part), "")
|
|
590
|
+
return first_part not in {"doc", "docx"}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _looks_like_feishu_url(input_value: str) -> bool:
|
|
594
|
+
if "://" not in input_value:
|
|
595
|
+
return False
|
|
596
|
+
parsed = urlparse(input_value)
|
|
597
|
+
host = parsed.netloc.lower()
|
|
598
|
+
return host.endswith("feishu.cn") or host.endswith("larksuite.com")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _info_from_item(item: ContentItem) -> FeishuObjectInfo | None:
|
|
602
|
+
object_type = item.raw_meta.get("type") or item.raw_meta.get("obj_type") or item.raw_meta.get("object_type")
|
|
603
|
+
token = item.raw_meta.get("token") or item.raw_meta.get("obj_token") or item.raw_meta.get("file_token")
|
|
604
|
+
title = item.raw_meta.get("title") or item.title
|
|
605
|
+
if not object_type and not token and not title:
|
|
606
|
+
return None
|
|
607
|
+
return FeishuObjectInfo(
|
|
608
|
+
object_type=str(object_type) if object_type else None,
|
|
609
|
+
token=str(token) if token else None,
|
|
610
|
+
title=str(title) if title else None,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _inspect_feishu_object(
|
|
615
|
+
input_value: str,
|
|
616
|
+
*,
|
|
617
|
+
runner: CommandRunner,
|
|
618
|
+
timeout: int,
|
|
619
|
+
backend: str,
|
|
620
|
+
fallback_info: FeishuObjectInfo | None = None,
|
|
621
|
+
) -> FeishuObjectInfo | TranscriptResult:
|
|
622
|
+
if fallback_info is not None and fallback_info.object_type and fallback_info.token:
|
|
623
|
+
return fallback_info
|
|
624
|
+
|
|
625
|
+
exit_code, stdout, stderr = runner(build_lark_drive_inspect_args(input_value), timeout=timeout)
|
|
626
|
+
if exit_code != 0:
|
|
627
|
+
return _command_failed_result(backend, exit_code, stdout, stderr, action="lark-cli drive +inspect")
|
|
628
|
+
|
|
629
|
+
payload = _loads_json(stdout)
|
|
630
|
+
if payload is None:
|
|
631
|
+
return _failed(backend, "backend_invalid_json", "lark-cli drive +inspect returned invalid JSON.")
|
|
632
|
+
if _payload_is_failed(payload):
|
|
633
|
+
return _failed(backend, "lark_cli_drive_inspect_failed", "lark-cli drive +inspect returned ok=false.")
|
|
634
|
+
return _parse_object_info(payload)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _parse_object_info(payload: Any) -> FeishuObjectInfo:
|
|
638
|
+
data = _payload_data(payload)
|
|
639
|
+
wiki_node = data.get("wiki_node") if isinstance(data, dict) and isinstance(data.get("wiki_node"), dict) else {}
|
|
640
|
+
return FeishuObjectInfo(
|
|
641
|
+
object_type=_first_string(data, ("type", "obj_type", "doc_type"))
|
|
642
|
+
or _first_string(wiki_node, ("obj_type", "type")),
|
|
643
|
+
token=_first_string(data, ("token", "obj_token", "file_token", "document_id"))
|
|
644
|
+
or _first_string(wiki_node, ("obj_token", "token", "file_token")),
|
|
645
|
+
title=_first_string(data, ("title", "name", "file_name")) or _first_string(wiki_node, ("title", "name")),
|
|
646
|
+
file_name=_first_string(data, ("file_name", "name", "title")),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _normalize_object_type(value: str | None) -> str | None:
|
|
651
|
+
if value is None:
|
|
652
|
+
return None
|
|
653
|
+
lowered = value.strip().lower()
|
|
654
|
+
if lowered == "sheets":
|
|
655
|
+
return "sheet"
|
|
656
|
+
return lowered or None
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _unsupported_feishu_object(backend: str, object_type: str | None, *, title: str | None = None) -> TranscriptResult:
|
|
660
|
+
del title
|
|
661
|
+
safe_type = object_type or "unknown"
|
|
662
|
+
return TranscriptResult(
|
|
663
|
+
text="",
|
|
664
|
+
status="failed",
|
|
665
|
+
source_level="partial_extraction",
|
|
666
|
+
backend=backend,
|
|
667
|
+
source_notes=[
|
|
668
|
+
"Feishu object type is outside Openmd V0 document extraction scope.",
|
|
669
|
+
"V0 only handles doc/docx native Markdown or Drive-native Markdown files.",
|
|
670
|
+
"Bitable, sheet, slides, mindnote, folder, and non-Markdown files are not extracted as documents.",
|
|
671
|
+
],
|
|
672
|
+
error_code="unsupported_feishu_object",
|
|
673
|
+
error_summary=sanitize_error_summary(f"Unsupported Feishu object type: {safe_type}."),
|
|
674
|
+
metadata={"object_type": safe_type},
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _command_failed_result(
|
|
679
|
+
backend: str,
|
|
680
|
+
exit_code: int,
|
|
681
|
+
stdout: str,
|
|
682
|
+
stderr: str,
|
|
683
|
+
*,
|
|
684
|
+
action: str,
|
|
685
|
+
) -> TranscriptResult:
|
|
686
|
+
code = "backend_dependency_missing" if _looks_like_missing_lark_cli(exit_code, stdout, stderr) else f"backend_exit_{exit_code}"
|
|
687
|
+
summary = "lark-cli executable is not available on PATH." if code == "backend_dependency_missing" else f"{action} failed with exit code {exit_code}."
|
|
688
|
+
return TranscriptResult(
|
|
689
|
+
text="",
|
|
690
|
+
status="failed",
|
|
691
|
+
source_level="partial_extraction",
|
|
692
|
+
backend=backend,
|
|
693
|
+
source_notes=[
|
|
694
|
+
f"{action} returned structured failure.",
|
|
695
|
+
"Openmd stored only the exit code and a sanitized summary, not raw command output.",
|
|
696
|
+
],
|
|
697
|
+
error_code=code,
|
|
698
|
+
error_summary=sanitize_error_summary(summary),
|
|
699
|
+
metadata={"exit_code": exit_code},
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _looks_like_missing_lark_cli(exit_code: int, stdout: str, stderr: str) -> bool:
|
|
704
|
+
text = f"{stdout} {stderr}".lower()
|
|
705
|
+
return (
|
|
706
|
+
exit_code in {127, 9009}
|
|
707
|
+
or "enoent" in text
|
|
708
|
+
or "not recognized" in text
|
|
709
|
+
or "command not found" in text
|
|
710
|
+
or "lark-cli: not found" in text
|
|
711
|
+
or "executable is not available" in text
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _failed(backend: str, code: str, error: object) -> TranscriptResult:
|
|
716
|
+
return TranscriptResult(
|
|
717
|
+
text="",
|
|
718
|
+
status="failed",
|
|
719
|
+
source_level="partial_extraction",
|
|
720
|
+
backend=backend,
|
|
721
|
+
source_notes=["Feishu backend returned structured failure."],
|
|
722
|
+
error_code=code,
|
|
723
|
+
error_summary=sanitize_error_summary(error),
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _loads_json(stdout: str) -> Any | None:
|
|
728
|
+
text = stdout.strip()
|
|
729
|
+
if not text:
|
|
730
|
+
return None
|
|
731
|
+
try:
|
|
732
|
+
return json.loads(text)
|
|
733
|
+
except json.JSONDecodeError:
|
|
734
|
+
start = text.find("{")
|
|
735
|
+
end = text.rfind("}")
|
|
736
|
+
if start == -1 or end == -1 or end <= start:
|
|
737
|
+
return None
|
|
738
|
+
try:
|
|
739
|
+
return json.loads(text[start : end + 1])
|
|
740
|
+
except json.JSONDecodeError:
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _payload_data(payload: Any) -> dict[str, Any]:
|
|
745
|
+
if not isinstance(payload, dict):
|
|
746
|
+
return {}
|
|
747
|
+
data = payload.get("data")
|
|
748
|
+
return data if isinstance(data, dict) else payload
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _payload_is_failed(payload: Any) -> bool:
|
|
752
|
+
if not isinstance(payload, dict):
|
|
753
|
+
return False
|
|
754
|
+
return payload.get("ok") is False or payload.get("success") is False
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _extract_content(payload: Any) -> str:
|
|
758
|
+
data = _payload_data(payload)
|
|
759
|
+
candidates: list[Any] = []
|
|
760
|
+
if isinstance(data, dict):
|
|
761
|
+
for key in ("document", "doc", "file", "markdown"):
|
|
762
|
+
value = data.get(key)
|
|
763
|
+
if isinstance(value, dict):
|
|
764
|
+
candidates.append(value)
|
|
765
|
+
candidates.append(data)
|
|
766
|
+
if isinstance(payload, dict):
|
|
767
|
+
candidates.append(payload)
|
|
768
|
+
|
|
769
|
+
for candidate in candidates:
|
|
770
|
+
value = _first_string(candidate, ("content", "markdown", "text", "body"))
|
|
771
|
+
if value and value.strip():
|
|
772
|
+
return value
|
|
773
|
+
return ""
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _extract_title(payload: Any) -> str | None:
|
|
777
|
+
data = _payload_data(payload)
|
|
778
|
+
candidates: list[Any] = []
|
|
779
|
+
if isinstance(data, dict):
|
|
780
|
+
for key in ("document", "doc", "file"):
|
|
781
|
+
value = data.get(key)
|
|
782
|
+
if isinstance(value, dict):
|
|
783
|
+
candidates.append(value)
|
|
784
|
+
candidates.append(data)
|
|
785
|
+
if isinstance(payload, dict):
|
|
786
|
+
candidates.append(payload)
|
|
787
|
+
for candidate in candidates:
|
|
788
|
+
title = _first_string(candidate, ("title", "file_name", "name"))
|
|
789
|
+
if title:
|
|
790
|
+
return title
|
|
791
|
+
return None
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _first_string(mapping: Any, keys: tuple[str, ...]) -> str | None:
|
|
795
|
+
if not isinstance(mapping, dict):
|
|
796
|
+
return None
|
|
797
|
+
for key in keys:
|
|
798
|
+
value = mapping.get(key)
|
|
799
|
+
if isinstance(value, str) and value.strip():
|
|
800
|
+
return value.strip()
|
|
801
|
+
return None
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _read_exported_markdown(tmpdir: Path, payload: Any | None) -> str:
|
|
805
|
+
for path in _exported_path_candidates(tmpdir, payload):
|
|
806
|
+
if not path.is_file():
|
|
807
|
+
continue
|
|
808
|
+
try:
|
|
809
|
+
text = path.read_text(encoding="utf-8")
|
|
810
|
+
except UnicodeDecodeError:
|
|
811
|
+
text = path.read_text(encoding="utf-8-sig", errors="replace")
|
|
812
|
+
if text.strip():
|
|
813
|
+
return text
|
|
814
|
+
return ""
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _exported_path_candidates(tmpdir: Path, payload: Any | None) -> list[Path]:
|
|
818
|
+
candidates: list[Path] = []
|
|
819
|
+
for value in _path_strings(payload):
|
|
820
|
+
path = Path(value)
|
|
821
|
+
if not path.is_absolute():
|
|
822
|
+
path = tmpdir / path
|
|
823
|
+
try:
|
|
824
|
+
path.resolve().relative_to(tmpdir.resolve())
|
|
825
|
+
except ValueError:
|
|
826
|
+
continue
|
|
827
|
+
candidates.append(path)
|
|
828
|
+
|
|
829
|
+
candidates.extend(sorted(tmpdir.rglob("*.md")))
|
|
830
|
+
candidates.extend(sorted(tmpdir.rglob("*.markdown")))
|
|
831
|
+
candidates.extend(path for path in sorted(tmpdir.rglob("*")) if path.is_file())
|
|
832
|
+
|
|
833
|
+
deduped: list[Path] = []
|
|
834
|
+
seen: set[Path] = set()
|
|
835
|
+
for path in candidates:
|
|
836
|
+
resolved = path.resolve()
|
|
837
|
+
if resolved in seen:
|
|
838
|
+
continue
|
|
839
|
+
seen.add(resolved)
|
|
840
|
+
deduped.append(path)
|
|
841
|
+
return deduped
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _path_strings(payload: Any | None) -> list[str]:
|
|
845
|
+
if payload is None:
|
|
846
|
+
return []
|
|
847
|
+
values: list[str] = []
|
|
848
|
+
if isinstance(payload, dict):
|
|
849
|
+
for key, value in payload.items():
|
|
850
|
+
if key in {"saved_path", "file_path", "output_path", "path"} and isinstance(value, str):
|
|
851
|
+
values.append(value)
|
|
852
|
+
else:
|
|
853
|
+
values.extend(_path_strings(value))
|
|
854
|
+
elif isinstance(payload, list):
|
|
855
|
+
for item in payload:
|
|
856
|
+
values.extend(_path_strings(item))
|
|
857
|
+
return values
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _is_markdown_file_name(value: str | None) -> bool:
|
|
861
|
+
return bool(value and value.lower().endswith((".md", ".markdown")))
|