zleap-parser 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. zleap_parser/THIRD_PARTY_NOTICES.md +27 -0
  2. zleap_parser/__init__.py +71 -0
  3. zleap_parser/adapters/__init__.py +168 -0
  4. zleap_parser/adapters/audio_adapter.py +368 -0
  5. zleap_parser/adapters/bmp_adapter.py +15 -0
  6. zleap_parser/adapters/code_adapter.py +177 -0
  7. zleap_parser/adapters/core/__init__.py +10 -0
  8. zleap_parser/adapters/core/base.py +115 -0
  9. zleap_parser/adapters/core/builtin.py +66 -0
  10. zleap_parser/adapters/core/engine.py +155 -0
  11. zleap_parser/adapters/core/mimetype.py +385 -0
  12. zleap_parser/adapters/core/registry.py +143 -0
  13. zleap_parser/adapters/docx_adapter.py +78 -0
  14. zleap_parser/adapters/gif_adapter.py +15 -0
  15. zleap_parser/adapters/html_adapter.py +295 -0
  16. zleap_parser/adapters/image_adapter.py +22 -0
  17. zleap_parser/adapters/jpg_adapter.py +15 -0
  18. zleap_parser/adapters/pdf_adapter.py +286 -0
  19. zleap_parser/adapters/png_adapter.py +16 -0
  20. zleap_parser/adapters/ppt_adapter.py +110 -0
  21. zleap_parser/adapters/rtf_adapter.py +203 -0
  22. zleap_parser/adapters/svg_adapter.py +15 -0
  23. zleap_parser/adapters/txt_adapter.py +20 -0
  24. zleap_parser/adapters/utils/__init__.py +53 -0
  25. zleap_parser/adapters/utils/code_utils.py +21 -0
  26. zleap_parser/adapters/utils/constants.py +8 -0
  27. zleap_parser/adapters/utils/docling.py +105 -0
  28. zleap_parser/adapters/utils/fallback.py +135 -0
  29. zleap_parser/adapters/utils/image.py +16 -0
  30. zleap_parser/adapters/utils/torch_noise.py +36 -0
  31. zleap_parser/adapters/utils/xlsx.py +455 -0
  32. zleap_parser/adapters/webp_adapter.py +15 -0
  33. zleap_parser/adapters/xlsx_adapter.py +102 -0
  34. zleap_parser/adapters/xml_adapter.py +63 -0
  35. zleap_parser/cache/__init__.py +258 -0
  36. zleap_parser/chat_utils.py +109 -0
  37. zleap_parser/config.py +217 -0
  38. zleap_parser/connector/__init__.py +85 -0
  39. zleap_parser/connector/connector.py +309 -0
  40. zleap_parser/connector/exceptions.py +34 -0
  41. zleap_parser/connector/loader/__init__.py +200 -0
  42. zleap_parser/connector/pipeline.py +218 -0
  43. zleap_parser/connector/sources/__init__.py +67 -0
  44. zleap_parser/connector/sources/api_request_connector.py +202 -0
  45. zleap_parser/connector/sources/bigdb_connector.py +224 -0
  46. zleap_parser/connector/sources/core/__init__.py +55 -0
  47. zleap_parser/connector/sources/core/base.py +175 -0
  48. zleap_parser/connector/sources/core/builtin.py +45 -0
  49. zleap_parser/connector/sources/core/chat_source.py +87 -0
  50. zleap_parser/connector/sources/core/db_engine.py +901 -0
  51. zleap_parser/connector/sources/core/git_repository.py +1271 -0
  52. zleap_parser/connector/sources/core/registry.py +81 -0
  53. zleap_parser/connector/sources/core/wxwork_sdk/WeWorkFinanceSdk_C.h +152 -0
  54. zleap_parser/connector/sources/core/wxwork_sdk/libWeWorkFinanceSdk_C.so +0 -0
  55. zleap_parser/connector/sources/db_connector.py +177 -0
  56. zleap_parser/connector/sources/deep_crawl_connector.py +756 -0
  57. zleap_parser/connector/sources/feishu_bot_connector.py +474 -0
  58. zleap_parser/connector/sources/gitee_connector.py +116 -0
  59. zleap_parser/connector/sources/github_connector.py +116 -0
  60. zleap_parser/connector/sources/gitlab_connector.py +141 -0
  61. zleap_parser/connector/sources/rss_connector.py +209 -0
  62. zleap_parser/connector/sources/salesmartly_chat_connector.py +481 -0
  63. zleap_parser/connector/sources/web_search_connector.py +77 -0
  64. zleap_parser/connector/sources/wxwork_connector.py +562 -0
  65. zleap_parser/connector/url_cache.py +403 -0
  66. zleap_parser/downloader/__init__.py +316 -0
  67. zleap_parser/downloader/attachments.py +98 -0
  68. zleap_parser/downloader/http_client.py +184 -0
  69. zleap_parser/exceptions.py +48 -0
  70. zleap_parser/extract/__init__.py +8 -0
  71. zleap_parser/extract/octx_export.py +528 -0
  72. zleap_parser/extract/sag.py +594 -0
  73. zleap_parser/licenses/Apache-2.0.txt +200 -0
  74. zleap_parser/parser.py +504 -0
  75. zleap_parser/py.typed +0 -0
  76. zleap_parser/search.py +466 -0
  77. zleap_parser/url_collector/__init__.py +28 -0
  78. zleap_parser/url_collector/browser.py +558 -0
  79. zleap_parser/url_collector/collector.py +90 -0
  80. zleap_parser/url_collector/extractor.py +174 -0
  81. zleap_parser/url_collector/images.py +134 -0
  82. zleap_parser/url_collector/markdown.py +372 -0
  83. zleap_parser/url_collector/models.py +114 -0
  84. zleap_parser/url_collector/resource_cache.py +78 -0
  85. zleap_parser/url_collector/security.py +216 -0
  86. zleap_parser/url_collector/webcrawler/__init__.py +1 -0
  87. zleap_parser/url_collector/webcrawler/article_extractor.py +2025 -0
  88. zleap_parser/url_collector/webcrawler/http_client.py +35 -0
  89. zleap_parser/url_collector/webcrawler/models.py +404 -0
  90. zleap_parser/url_collector/webcrawler/proxy.py +66 -0
  91. zleap_parser/url_collector/webcrawler/resource_cache.py +129 -0
  92. zleap_parser/usage.py +176 -0
  93. zleap_parser/utils.py +328 -0
  94. zleap_parser-0.1.4.dist-info/METADATA +268 -0
  95. zleap_parser-0.1.4.dist-info/RECORD +98 -0
  96. zleap_parser-0.1.4.dist-info/WHEEL +4 -0
  97. zleap_parser-0.1.4.dist-info/licenses/LICENSE +21 -0
  98. zleap_parser-0.1.4.dist-info/licenses/LICENSES/Apache-2.0.txt +200 -0
@@ -0,0 +1,27 @@
1
+ # Third-Party Notices
2
+
3
+ ## MinerU HTML webcrawler rules
4
+
5
+ URL 正文提取、正文图片筛选和 Markdown 渲染的部分实现位于:
6
+
7
+ - `src/zleap_parser/url_collector/webcrawler/article_extractor.py`(参考原 `htmlx.py` 逐字节复制后改名)
8
+ - `src/zleap_parser/url_collector/webcrawler/models.py`
9
+ - `src/zleap_parser/url_collector/webcrawler/http_client.py`(同步入口保留参考异步签名,依赖延迟导入)
10
+ - `src/zleap_parser/url_collector/webcrawler/proxy.py`
11
+ - `src/zleap_parser/url_collector/webcrawler/resource_cache.py`
12
+ - `src/zleap_parser/url_collector/markdown.py`
13
+ - `src/zleap_parser/url_collector/resource_cache.py`
14
+
15
+ `article_extractor.py`(原 `htmlx.py`)副本 SHA-256:
16
+ `3b54647de752f05d28c65bc2229b3a85f6d09583237188977c4f61d6608ceeaf`
17
+
18
+ 这些实现派生自用户提供的 `mineru-html-feat-crawl-0714` 中
19
+ `dripper/crawl/vendors/webcrawler` 代码,原项目采用 Apache License 2.0。
20
+ Apache License 2.0 全文随本项目发布于 `LICENSES/Apache-2.0.txt`。
21
+
22
+ 原项目 NOTICE:
23
+
24
+ > This project contains code and model weights derived from Qwen3.
25
+ > Original Qwen3 Copyright 2024 Alibaba Cloud, licensed under Apache License 2.0.
26
+ > Modifications and additional training Copyright 2025 OpenDatalab Shanghai AILab,
27
+ > licensed under Apache License 2.0.
@@ -0,0 +1,71 @@
1
+ """zleap_parser:zleap 文档解析 SDK。
2
+
3
+ 把本地文件或远程 HTML URL 解析为 markdown,经 ``zleap-sag`` 提取结构化产物、
4
+ ``octx`` 打包为 ``*.octx`` 归档文件路径;远程 HTML 由同步 Camoufox 渲染。
5
+ 返回值携带本次调用的 LLM token 用量。
6
+
7
+ 快速上手::
8
+
9
+ import zleap_parser
10
+ from zleap_parser import LLMConfig, EmbeddingConfig
11
+
12
+ zleap_parser.config(
13
+ llm=LLMConfig(base_url="https://api.xxx.com/v1", api_key="sk-...", model="gpt-4o"),
14
+ embedding=EmbeddingConfig(model="text-embedding-3", dimensions=1024),
15
+ )
16
+
17
+ parser = Parser()
18
+ result = parser.parse(file="/path/to/document.pdf") # 或 parse(url="https://...")
19
+ octx_path = result["result"] # *.octx 归档路径
20
+ usage = result["usage"] # LLM token 用量:prompt_tokens / completion_tokens / total_tokens
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from zleap_parser.adapters import BaseAdapter
26
+ from zleap_parser.config import (
27
+ CacheConfig,
28
+ Config,
29
+ EmbeddingConfig,
30
+ LLMConfig,
31
+ RedisConfig,
32
+ config,
33
+ get_global_config,
34
+ set_global_config,
35
+ )
36
+ from zleap_parser.exceptions import (
37
+ AdapterNotFoundError,
38
+ ConfigError,
39
+ ConversionError,
40
+ DownloadError,
41
+ ExtractError,
42
+ TimeoutError,
43
+ ZleapParserError,
44
+ )
45
+ from zleap_parser.parser import Parser
46
+ from zleap_parser.search import SEARCH_SOURCE_DOMAINS, SearchMode, SearchResult
47
+
48
+ __version__ = "0.1.3"
49
+
50
+ __all__ = [
51
+ "AdapterNotFoundError",
52
+ "BaseAdapter",
53
+ "CacheConfig",
54
+ "Config",
55
+ "ConfigError",
56
+ "ConversionError",
57
+ "DownloadError",
58
+ "EmbeddingConfig",
59
+ "ExtractError",
60
+ "LLMConfig",
61
+ "Parser",
62
+ "RedisConfig",
63
+ "SEARCH_SOURCE_DOMAINS",
64
+ "SearchMode",
65
+ "SearchResult",
66
+ "TimeoutError",
67
+ "ZleapParserError",
68
+ "config",
69
+ "get_global_config",
70
+ "set_global_config",
71
+ ]
@@ -0,0 +1,168 @@
1
+ """格式适配器(插件:注册制、热插拔、可嵌套、防循环)。
2
+
3
+ 新增格式 = 在适配器目录(本包)下新增
4
+ :class:`~zleap_parser.adapters.core.base.BaseAdapter` 子类 + 注册,不改核心框架。
5
+
6
+ 目录约定:
7
+
8
+ - ``adapters/*_adapter.py``:格式适配器插件(txt / code / html / image / pdf /
9
+ docx / ppt / xlsx / xml / audio,统一 ``<格式>_adapter.py`` 命名,一眼可辨;
10
+ ``code_adapter.py`` 为合并实现,单一适配器覆盖全部源码类格式);
11
+ - ``adapters/core/``:框架基础设施(基类 / 注册表 / 引擎 / mimetype 检测),
12
+ 不是格式插件,请勿在 core 下新增格式;
13
+ - ``adapters/utils/``:适配器共享工具(docling 转换 / fallback runner /
14
+ torch 噪音静音 / 源码围栏包装 / 图片与 xlsx 资源提取等),非适配器文件一律放这里。
15
+
16
+ **懒加载约定(M2.5 S4)**:适配器实现模块经 :func:`__getattr__`(PEP 562)
17
+ 按需导入——``import zleap_parser.adapters`` 只加载框架核心(基类/注册表/引擎),
18
+ 不连带导入 pdf/audio 等重量适配器(避免 docling/torch 等可选依赖 import 链)。
19
+ 各格式适配器类与公开 mimetype 常量仍可直接 ``from zleap_parser.adapters import X``,
20
+ 首次访问时懒加载对应模块。
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib
26
+ from typing import Any, Dict
27
+
28
+ from zleap_parser.adapters.core.base import BaseAdapter, _CallableAdapter # noqa: F401
29
+ from zleap_parser.adapters.core.engine import AdapterEngine, ensure_readable_file # noqa: F401
30
+ from zleap_parser.adapters.core.mimetype import M4A_MIMETYPE, detect_mimetype_by_magic
31
+ from zleap_parser.adapters.core.registry import AdapterRegistry
32
+
33
+ #: 符号 → 定义模块(懒加载映射):仅访问到该符号时才 import 其模块。
34
+ _LAZY_IMPORTS: Dict[str, str] = {
35
+ # audio_adapter(音视频 ASR,重:docling/torch/transformers 链路)
36
+ "AUDIO_MIMETYPES": "zleap_parser.adapters.audio_adapter",
37
+ "VIDEO_MIMETYPES": "zleap_parser.adapters.audio_adapter",
38
+ "AudioAdapter": "zleap_parser.adapters.audio_adapter",
39
+ # 图片类
40
+ "BMP_MIMETYPE": "zleap_parser.adapters.bmp_adapter",
41
+ "BmpAdapter": "zleap_parser.adapters.bmp_adapter",
42
+ "GIF_MIMETYPE": "zleap_parser.adapters.gif_adapter",
43
+ "GifAdapter": "zleap_parser.adapters.gif_adapter",
44
+ "JPG_MIMETYPE": "zleap_parser.adapters.jpg_adapter",
45
+ "JpgAdapter": "zleap_parser.adapters.jpg_adapter",
46
+ "PNG_MIMETYPE": "zleap_parser.adapters.png_adapter",
47
+ "PngAdapter": "zleap_parser.adapters.png_adapter",
48
+ "SVG_MIMETYPE": "zleap_parser.adapters.svg_adapter",
49
+ "SvgAdapter": "zleap_parser.adapters.svg_adapter",
50
+ "WEBP_MIMETYPE": "zleap_parser.adapters.webp_adapter",
51
+ "WebpAdapter": "zleap_parser.adapters.webp_adapter",
52
+ "ImageAdapter": "zleap_parser.adapters.image_adapter",
53
+ # 代码/文本类
54
+ "C_MIMETYPE": "zleap_parser.adapters.code_adapter",
55
+ "CPP_MIMETYPE": "zleap_parser.adapters.code_adapter",
56
+ "CSHARP_MIMETYPE": "zleap_parser.adapters.code_adapter",
57
+ "CSS_MIMETYPE": "zleap_parser.adapters.code_adapter",
58
+ "DART_MIMETYPE": "zleap_parser.adapters.code_adapter",
59
+ "GO_MIMETYPE": "zleap_parser.adapters.code_adapter",
60
+ "JAVA_MIMETYPE": "zleap_parser.adapters.code_adapter",
61
+ "JAVASCRIPT_MIMETYPE": "zleap_parser.adapters.code_adapter",
62
+ "JSON_MIMETYPE": "zleap_parser.adapters.code_adapter",
63
+ "KOTLIN_MIMETYPE": "zleap_parser.adapters.code_adapter",
64
+ "LUA_MIMETYPE": "zleap_parser.adapters.code_adapter",
65
+ "PHP_MIMETYPE": "zleap_parser.adapters.code_adapter",
66
+ "PYTHON_MIMETYPE": "zleap_parser.adapters.code_adapter",
67
+ "R_MIMETYPE": "zleap_parser.adapters.code_adapter",
68
+ "RUBY_MIMETYPE": "zleap_parser.adapters.code_adapter",
69
+ "RUST_MIMETYPE": "zleap_parser.adapters.code_adapter",
70
+ "SCALA_MIMETYPE": "zleap_parser.adapters.code_adapter",
71
+ "SHELL_MIMETYPE": "zleap_parser.adapters.code_adapter",
72
+ "SQL_MIMETYPE": "zleap_parser.adapters.code_adapter",
73
+ "SWIFT_MIMETYPE": "zleap_parser.adapters.code_adapter",
74
+ "TFT_MIMETYPE": "zleap_parser.adapters.code_adapter",
75
+ "TOML_MIMETYPE": "zleap_parser.adapters.code_adapter",
76
+ "TYPESCRIPT_MIMETYPE": "zleap_parser.adapters.code_adapter",
77
+ "YAML_MIMETYPE": "zleap_parser.adapters.code_adapter",
78
+ "CodeAdapter": "zleap_parser.adapters.code_adapter",
79
+ "TxtAdapter": "zleap_parser.adapters.txt_adapter",
80
+ # 文档类(docx/ppt/xlsx/pdf/rtf:docling/markitdown 等可选依赖 import 链)
81
+ "DOCX_MIMETYPE": "zleap_parser.adapters.docx_adapter",
82
+ "DocxAdapter": "zleap_parser.adapters.docx_adapter",
83
+ "HTMLAdapter": "zleap_parser.adapters.html_adapter",
84
+ "PdfAdapter": "zleap_parser.adapters.pdf_adapter",
85
+ "PPTX_MIMETYPE": "zleap_parser.adapters.ppt_adapter",
86
+ "PptAdapter": "zleap_parser.adapters.ppt_adapter",
87
+ "RTF_MIMETYPE": "zleap_parser.adapters.rtf_adapter",
88
+ "RTF_MIMETYPES": "zleap_parser.adapters.rtf_adapter",
89
+ "RtfAdapter": "zleap_parser.adapters.rtf_adapter",
90
+ "XLSX_MIMETYPE": "zleap_parser.adapters.xlsx_adapter",
91
+ "XlsxAdapter": "zleap_parser.adapters.xlsx_adapter",
92
+ "XMLAdapter": "zleap_parser.adapters.xml_adapter",
93
+ }
94
+
95
+ __all__ = [
96
+ "AdapterEngine",
97
+ "AdapterRegistry",
98
+ "AUDIO_MIMETYPES",
99
+ "AudioAdapter",
100
+ "BaseAdapter",
101
+ "BMP_MIMETYPE",
102
+ "BmpAdapter",
103
+ "C_MIMETYPE",
104
+ "CPP_MIMETYPE",
105
+ "CSHARP_MIMETYPE",
106
+ "CSS_MIMETYPE",
107
+ "CodeAdapter",
108
+ "DART_MIMETYPE",
109
+ "DOCX_MIMETYPE",
110
+ "DocxAdapter",
111
+ "GIF_MIMETYPE",
112
+ "GifAdapter",
113
+ "GO_MIMETYPE",
114
+ "HTMLAdapter",
115
+ "ImageAdapter",
116
+ "JAVA_MIMETYPE",
117
+ "JAVASCRIPT_MIMETYPE",
118
+ "JSON_MIMETYPE",
119
+ "JPG_MIMETYPE",
120
+ "JpgAdapter",
121
+ "KOTLIN_MIMETYPE",
122
+ "LUA_MIMETYPE",
123
+ "M4A_MIMETYPE",
124
+ "PHP_MIMETYPE",
125
+ "PdfAdapter",
126
+ "PNG_MIMETYPE",
127
+ "PngAdapter",
128
+ "PPTX_MIMETYPE",
129
+ "PptAdapter",
130
+ "PYTHON_MIMETYPE",
131
+ "R_MIMETYPE",
132
+ "RUBY_MIMETYPE",
133
+ "RUST_MIMETYPE",
134
+ "RTF_MIMETYPE",
135
+ "RTF_MIMETYPES",
136
+ "RtfAdapter",
137
+ "SCALA_MIMETYPE",
138
+ "SHELL_MIMETYPE",
139
+ "SQL_MIMETYPE",
140
+ "SVG_MIMETYPE",
141
+ "SvgAdapter",
142
+ "SWIFT_MIMETYPE",
143
+ "TFT_MIMETYPE",
144
+ "TOML_MIMETYPE",
145
+ "TYPESCRIPT_MIMETYPE",
146
+ "TxtAdapter",
147
+ "VIDEO_MIMETYPES",
148
+ "WEBP_MIMETYPE",
149
+ "WebpAdapter",
150
+ "XLSX_MIMETYPE",
151
+ "XlsxAdapter",
152
+ "XMLAdapter",
153
+ "YAML_MIMETYPE",
154
+ "detect_mimetype_by_magic",
155
+ ]
156
+
157
+
158
+ def __getattr__(name: str) -> Any:
159
+ """PEP 562 懒加载:仅当真正访问适配器符号时才 import 其模块。
160
+
161
+ - 未声明懒加载的名字直接抛 AttributeError(与模块级 ``__getattr__`` 约定一致);
162
+ - 命中后导入模块并取回符号,后续访问走模块缓存,不再重复导入。
163
+ """
164
+ module_name = _LAZY_IMPORTS.get(name)
165
+ if module_name is None:
166
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
167
+ module = importlib.import_module(module_name)
168
+ return getattr(module, name)
@@ -0,0 +1,368 @@
1
+ """音视频 ASR 适配器:FFmpeg 标准化音轨(含响度归一化),Docling 转写为会话 Markdown。
2
+
3
+ 所有支持的音频/视频容器先统一转换为 16 kHz、16-bit、单声道 WAV,并做 -23 LUFS
4
+ 响度归一化(低音量录音直接喂给 whisper 极易产生重复幻觉),再交给 Docling ASR。
5
+ 转写片段先做相邻去重合并(消除 whisper 滑窗重叠重复与幻觉循环),复用 connector
6
+ 的 ``chat_conversation`` / ``chat_message`` 结构和 Markdown 渲染器,因此输出与
7
+ 飞书等聊天数据源保持一致。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import logging
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
18
+ from contextlib import contextmanager
19
+ from importlib import import_module
20
+ from pathlib import Path
21
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
22
+
23
+ from uuid6 import uuid7
24
+
25
+ from zleap_parser.adapters.core.base import BaseAdapter
26
+ from zleap_parser.adapters.utils.torch_noise import quiet_torch_noise
27
+ from zleap_parser.exceptions import ConfigError, ConversionError
28
+
29
+ logger = logging.getLogger("zleap_parser.adapters.audio_adapter")
30
+
31
+ AUDIO_MIMETYPES = [
32
+ "audio/aac",
33
+ "audio/flac",
34
+ "audio/mp4",
35
+ "audio/mpeg",
36
+ "audio/ogg",
37
+ "audio/wav",
38
+ "audio/webm",
39
+ "audio/x-m4a",
40
+ ]
41
+ VIDEO_MIMETYPES = [
42
+ "video/avi",
43
+ "video/mp4",
44
+ "video/mpeg",
45
+ "video/ogg",
46
+ "video/quicktime",
47
+ "video/webm",
48
+ "video/x-matroska",
49
+ "video/x-msvideo",
50
+ ]
51
+
52
+ MODEL_SPECS = {
53
+ "tiny": "WHISPER_TINY",
54
+ "base": "WHISPER_BASE",
55
+ "small": "WHISPER_SMALL",
56
+ "medium": "WHISPER_MEDIUM",
57
+ "large": "WHISPER_LARGE",
58
+ "turbo": "WHISPER_TURBO",
59
+ "turbo_native": "WHISPER_TURBO_NATIVE",
60
+ }
61
+
62
+ # 响度归一化目标(EBU R128 广播标准):低音量录音先抬升,降低 whisper 幻觉。
63
+ LOUDNORM_FILTER = "loudnorm=I=-23:TP=-1.5:LRA=11"
64
+ # 去重阈值:相邻同文本片段的合并时间容忍(秒,whisper 滑窗重叠重复是首尾相接的);
65
+ _MERGE_GAP_TOLERANCE = 0.3
66
+ # 物理上不可能的超短片段时长(秒):whisper 幻觉循环会产出 0.02s 的完整单词。
67
+ _MIN_SEGMENT_DURATION = 0.1
68
+
69
+ __all__ = [
70
+ "AUDIO_MIMETYPES",
71
+ "MODEL_SPECS",
72
+ "VIDEO_MIMETYPES",
73
+ "AudioAdapter",
74
+ "build_asr_messages",
75
+ "media_to_markdown",
76
+ "prepare_asr_audio",
77
+ ]
78
+
79
+
80
+ @contextmanager
81
+ def prepare_asr_audio(source: str, work_dir: Optional[str] = None) -> Iterator[str]:
82
+ """用 FFmpeg 抽取音轨并统一为 ASR 友好的 16 kHz 单声道 PCM WAV(-23 LUFS)。"""
83
+ if not os.path.isfile(source):
84
+ raise ConversionError(f"file not found: {source}")
85
+ ffmpeg = shutil.which("ffmpeg")
86
+ if not ffmpeg:
87
+ raise ConversionError("音视频转写需要 ffmpeg")
88
+
89
+ temporary: Optional[tempfile.TemporaryDirectory[str]] = None
90
+ if work_dir:
91
+ os.makedirs(work_dir, exist_ok=True)
92
+ output_dir = work_dir
93
+ else:
94
+ temporary = tempfile.TemporaryDirectory(prefix="zleap-asr-")
95
+ output_dir = temporary.name
96
+ wav_path = os.path.join(output_dir, f"{Path(source).stem}_asr.wav")
97
+ try:
98
+ try:
99
+ completed = subprocess.run(
100
+ [
101
+ ffmpeg,
102
+ "-hide_banner",
103
+ "-loglevel",
104
+ "error",
105
+ "-y",
106
+ "-i",
107
+ source,
108
+ "-vn",
109
+ "-map",
110
+ "0:a:0",
111
+ "-af",
112
+ LOUDNORM_FILTER,
113
+ "-acodec",
114
+ "pcm_s16le",
115
+ "-ac",
116
+ "1",
117
+ "-ar",
118
+ "16000",
119
+ wav_path,
120
+ ],
121
+ check=False,
122
+ capture_output=True,
123
+ text=True,
124
+ timeout=3600,
125
+ )
126
+ except subprocess.TimeoutExpired as exc:
127
+ raise ConversionError("ffmpeg 音轨预处理超时") from exc
128
+ if completed.returncode != 0 or not os.path.isfile(wav_path):
129
+ detail = (completed.stderr or "ffmpeg 未产出音轨").strip()
130
+ raise ConversionError(f"ffmpeg 音轨预处理失败: {detail}")
131
+ yield wav_path
132
+ finally:
133
+ if temporary is not None:
134
+ temporary.cleanup()
135
+
136
+
137
+ def _speaker_for_interval(diarization: Any, start: float, end: float) -> Optional[str]:
138
+ """按时间区间与各说话人片段的重叠时长,返回重合度最高的说话人标签。"""
139
+ best_speaker: Optional[str] = None
140
+ best_overlap = 0.0
141
+ for segment in getattr(diarization, "segments", []):
142
+ overlap = max(0.0, min(end, float(segment.end_time)) - max(start, float(segment.start_time)))
143
+ if overlap > best_overlap:
144
+ best_overlap = overlap
145
+ best_speaker = str(segment.speaker)
146
+ return best_speaker
147
+
148
+
149
+ def _speaker_name(index: int) -> str:
150
+ """按出现顺序生成稳定的说话人名称(speaker_1、speaker_2 …)。"""
151
+ return f"speaker_{index + 1}"
152
+
153
+
154
+ def build_asr_messages(
155
+ conversation_id: str,
156
+ result: Any,
157
+ diarization: Optional[Any] = None,
158
+ ) -> List[Dict[str, Any]]:
159
+ """把 Docling ASR 文本片段转换为统一 ``chat_message`` 列表。
160
+
161
+ 逐段过滤与合并,消除 whisper 的两类重复产物:
162
+ - 时长不足 ``_MIN_SEGMENT_DURATION`` 的片段直接丢弃(幻觉循环产出的
163
+ 0.02s「那就是」在物理上不可能是真实语音);
164
+ - 时间连续/接近(间隙 ≤ ``_MERGE_GAP_TOLERANCE``)且文本相同的片段合并为
165
+ 一条(30s 滑窗重叠会把同一句转写两次,甚至被分给不同说话人)。
166
+ """
167
+ # 先收集原始片段并按起始时间排序,保证合并顺序稳定。
168
+ items: List[Tuple[str, float, float]] = []
169
+ for item in getattr(result.document, "texts", []):
170
+ text = str(getattr(item, "text", "")).strip()
171
+ sources = getattr(item, "source", None)
172
+ if not text or not sources:
173
+ continue
174
+ track = sources[0]
175
+ start = max(0.0, float(track.start_time))
176
+ end = max(start, float(track.end_time))
177
+ items.append((text, start, end))
178
+ items.sort(key=lambda item: item[1])
179
+
180
+ merged: List[Tuple[str, float, float]] = []
181
+ for text, start, end in items:
182
+ if end - start < _MIN_SEGMENT_DURATION:
183
+ continue
184
+ if (
185
+ merged
186
+ and merged[-1][0] == text
187
+ and start <= merged[-1][2] + _MERGE_GAP_TOLERANCE
188
+ ):
189
+ # 相邻同文本(窗口重叠重复/语气词连击)→ 合并,时间范围扩展。
190
+ merged[-1] = (merged[-1][0], merged[-1][1], max(merged[-1][2], end))
191
+ else:
192
+ merged.append((text, start, end))
193
+
194
+ messages: List[Dict[str, Any]] = []
195
+ speaker_names: Dict[str, str] = {}
196
+ for text, start, end in merged:
197
+ speaker_id = _speaker_for_interval(diarization, start, end) if diarization is not None else None
198
+ if speaker_id is not None and speaker_id not in speaker_names:
199
+ speaker_names[speaker_id] = _speaker_name(len(speaker_names))
200
+ sender_name = speaker_names.get(speaker_id or "", "speaker_unknown" if diarization is not None else "转写")
201
+ message_id = str(uuid7())
202
+ messages.append(
203
+ {
204
+ "chat_message_id": message_id,
205
+ "chat_conversation_id": conversation_id,
206
+ "type": "TEXT",
207
+ "timestamp": 0,
208
+ "content": text,
209
+ "sender_id": speaker_id or "asr",
210
+ "sender_name": sender_name,
211
+ "sender_avatar": "",
212
+ "extra_data": {"speaker_id": speaker_id or "", "source": "asr"},
213
+ "source_id": message_id,
214
+ "raw_content": text,
215
+ "parent_id": "",
216
+ "group_id": message_id,
217
+ "segment": len(messages),
218
+ "start_time": start,
219
+ "end_time": end,
220
+ }
221
+ )
222
+ return messages
223
+
224
+
225
+ def _transcribe(prepared_source: str, model: str, language: Optional[str]) -> Any:
226
+ """调用 Docling ASR 转写预处理音频,返回转换结果对象。
227
+
228
+ :param prepared_source: 预处理后的 WAV 路径。
229
+ :param model: ASR 模型名(MODEL_SPECS 的键)。
230
+ :param language: 可选语言代码,为 None 时自动识别。
231
+ :return: Docling 转换结果(``document.texts`` 为转写片段)。
232
+ """
233
+ quiet_torch_noise() # 副作用归位:首次实际转写时才静音 torch/transformers 噪音
234
+ try:
235
+ from docling.datamodel import asr_model_specs
236
+ from docling.datamodel.base_models import InputFormat
237
+ from docling.datamodel.pipeline_options import AsrPipelineOptions
238
+ from docling.document_converter import AudioFormatOption, DocumentConverter
239
+ from docling.pipeline.asr_pipeline import AsrPipeline
240
+ except ImportError as exc:
241
+ raise ConversionError(
242
+ "音视频转写依赖不完整,请启用 media extra "
243
+ "(uv: `uv run --extra media ...`;pip: `pip install zleap_parser[media]`)"
244
+ ) from exc
245
+
246
+ pipeline_options = AsrPipelineOptions()
247
+ pipeline_options.asr_options = copy.deepcopy(getattr(asr_model_specs, MODEL_SPECS[model]))
248
+ if language:
249
+ asr_options: Any = pipeline_options.asr_options
250
+ asr_options.language = language
251
+ converter = DocumentConverter(
252
+ format_options={
253
+ InputFormat.AUDIO: AudioFormatOption(
254
+ pipeline_cls=AsrPipeline,
255
+ pipeline_options=pipeline_options,
256
+ )
257
+ }
258
+ )
259
+ try:
260
+ return converter.convert(prepared_source)
261
+ except ImportError as exc:
262
+ raise ConversionError(
263
+ "音视频转写依赖不完整,请启用 media extra "
264
+ "(uv: `uv run --extra media ...`;pip: `pip install zleap_parser[media]`)"
265
+ ) from exc
266
+
267
+
268
+ def _diarize(prepared_source: str, speakers: Optional[int]) -> Any:
269
+ """对预处理音频做说话人分离,返回分离结果对象。
270
+
271
+ :param prepared_source: 预处理后的 WAV 路径。
272
+ :param speakers: 期望说话人数,为 None 时自动推断。
273
+ :return: 含 ``segments`` 的 Docling 说话人分离结果。
274
+ """
275
+ try:
276
+ from docling.utils.speaker_diarization import diarize
277
+
278
+ # Docling 会吞掉这些可选依赖的 ImportError 并返回空结果,预检后才能给出
279
+ # 可操作的错误信息;导入 resemblyzer 同时覆盖其 pkg_resources 依赖。
280
+ for dependency in ("librosa", "soundfile", "resemblyzer", "sklearn.cluster"):
281
+ import_module(dependency)
282
+ except ImportError as exc:
283
+ raise ConversionError(
284
+ "说话人分离依赖不完整,请启用 media extra "
285
+ "(uv: `uv run --extra media ...`;pip: `pip install zleap_parser[media]`)"
286
+ ) from exc
287
+ result = diarize(Path(prepared_source), num_speakers=speakers)
288
+ if not getattr(result, "segments", None):
289
+ raise ConversionError("说话人分离未产生有效片段")
290
+ return result
291
+
292
+
293
+ def media_to_markdown(
294
+ file: str,
295
+ *,
296
+ model: str = "turbo",
297
+ language: Optional[str] = None,
298
+ enable_diarization: bool = True,
299
+ speakers: Optional[int] = None,
300
+ work_dir: Optional[str] = None,
301
+ ) -> Tuple[Optional[str], List[str]]:
302
+ """音频/视频 → 会话 Markdown;原始媒体文件作为关联附件返回。"""
303
+ # 延迟导入避免 Parser -> adapters -> connector -> Parser 的初始化环。
304
+ from zleap_parser.chat_utils import build_chat_conversation, conversation_to_markdown
305
+
306
+ with prepare_asr_audio(file, work_dir=work_dir) as prepared_source:
307
+ result = _transcribe(prepared_source, model, language)
308
+ diarization = _diarize(prepared_source, speakers) if enable_diarization else None
309
+ conversation = build_chat_conversation(
310
+ Path(file).stem,
311
+ title=Path(file).stem,
312
+ extra_data={
313
+ "source_type": "media_asr",
314
+ "model": model,
315
+ "language": language or "auto",
316
+ "num_speakers": getattr(diarization, "num_speakers", 0),
317
+ },
318
+ )
319
+ messages = build_asr_messages(conversation["chat_conversation_id"], result, diarization)
320
+ if not messages:
321
+ return None, []
322
+ return conversation_to_markdown(conversation, messages), [file]
323
+
324
+
325
+ class AudioAdapter(BaseAdapter):
326
+ """支持音频和视频容器的 Docling ASR 格式适配器。"""
327
+
328
+ mimetypes = [*AUDIO_MIMETYPES, *VIDEO_MIMETYPES]
329
+ accepts_work_dir = True # ASR 预处理 WAV 落盘 work_dir
330
+ requires: tuple = ("docling",) # ASR 转写依赖 docling(media extra)
331
+
332
+ def __init__(
333
+ self,
334
+ *,
335
+ model: str = "turbo",
336
+ language: Optional[str] = None,
337
+ enable_diarization: bool = True,
338
+ speakers: Optional[int] = None,
339
+ ) -> None:
340
+ if model not in MODEL_SPECS:
341
+ raise ConfigError(f"不支持的 ASR 模型: {model}")
342
+ if language is not None and (not isinstance(language, str) or not language.strip()):
343
+ raise ConfigError("language 必须是非空语言代码")
344
+ if not isinstance(enable_diarization, bool):
345
+ raise ConfigError("enable_diarization 必须是布尔值")
346
+ if speakers is not None and (
347
+ isinstance(speakers, bool) or not isinstance(speakers, int) or not 2 <= speakers <= 8
348
+ ):
349
+ raise ConfigError("speakers 必须是 2 到 8 的整数")
350
+ self.model = model
351
+ self.language = language.strip() if language else None
352
+ self.enable_diarization = enable_diarization
353
+ self.speakers = speakers
354
+
355
+ def is_available(self) -> bool:
356
+ """运行环境预检:docling 模块 + ffmpeg 外部二进制均可用。"""
357
+ return super().is_available() and shutil.which("ffmpeg") is not None
358
+
359
+ def parse(self, file: str, *, work_dir: Optional[str] = None) -> Tuple[Optional[str], List[str]]:
360
+ """音视频文件转会话 Markdown(ASR 转写 + 可选说话人分离);原始媒体文件作为关联附件返回。"""
361
+ return media_to_markdown(
362
+ file,
363
+ model=self.model,
364
+ language=self.language,
365
+ enable_diarization=self.enable_diarization,
366
+ speakers=self.speakers,
367
+ work_dir=work_dir,
368
+ )
@@ -0,0 +1,15 @@
1
+ """BMP 图片适配器:生成 Markdown 图片块并保留原始 BMP 附件。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from zleap_parser.adapters.core.mimetype import BMP_MIMETYPE
8
+ from zleap_parser.adapters.image_adapter import ImageAdapter
9
+
10
+ __all__ = ["BMP_MIMETYPE", "BmpAdapter"]
11
+
12
+
13
+ class BmpAdapter(ImageAdapter):
14
+ """BMP 专用适配器,复用通用图片的 Markdown 和附件处理。"""
15
+ mimetypes: List[str] = [BMP_MIMETYPE]