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.
- zleap_parser/THIRD_PARTY_NOTICES.md +27 -0
- zleap_parser/__init__.py +71 -0
- zleap_parser/adapters/__init__.py +168 -0
- zleap_parser/adapters/audio_adapter.py +368 -0
- zleap_parser/adapters/bmp_adapter.py +15 -0
- zleap_parser/adapters/code_adapter.py +177 -0
- zleap_parser/adapters/core/__init__.py +10 -0
- zleap_parser/adapters/core/base.py +115 -0
- zleap_parser/adapters/core/builtin.py +66 -0
- zleap_parser/adapters/core/engine.py +155 -0
- zleap_parser/adapters/core/mimetype.py +385 -0
- zleap_parser/adapters/core/registry.py +143 -0
- zleap_parser/adapters/docx_adapter.py +78 -0
- zleap_parser/adapters/gif_adapter.py +15 -0
- zleap_parser/adapters/html_adapter.py +295 -0
- zleap_parser/adapters/image_adapter.py +22 -0
- zleap_parser/adapters/jpg_adapter.py +15 -0
- zleap_parser/adapters/pdf_adapter.py +286 -0
- zleap_parser/adapters/png_adapter.py +16 -0
- zleap_parser/adapters/ppt_adapter.py +110 -0
- zleap_parser/adapters/rtf_adapter.py +203 -0
- zleap_parser/adapters/svg_adapter.py +15 -0
- zleap_parser/adapters/txt_adapter.py +20 -0
- zleap_parser/adapters/utils/__init__.py +53 -0
- zleap_parser/adapters/utils/code_utils.py +21 -0
- zleap_parser/adapters/utils/constants.py +8 -0
- zleap_parser/adapters/utils/docling.py +105 -0
- zleap_parser/adapters/utils/fallback.py +135 -0
- zleap_parser/adapters/utils/image.py +16 -0
- zleap_parser/adapters/utils/torch_noise.py +36 -0
- zleap_parser/adapters/utils/xlsx.py +455 -0
- zleap_parser/adapters/webp_adapter.py +15 -0
- zleap_parser/adapters/xlsx_adapter.py +102 -0
- zleap_parser/adapters/xml_adapter.py +63 -0
- zleap_parser/cache/__init__.py +258 -0
- zleap_parser/chat_utils.py +109 -0
- zleap_parser/config.py +217 -0
- zleap_parser/connector/__init__.py +85 -0
- zleap_parser/connector/connector.py +309 -0
- zleap_parser/connector/exceptions.py +34 -0
- zleap_parser/connector/loader/__init__.py +200 -0
- zleap_parser/connector/pipeline.py +218 -0
- zleap_parser/connector/sources/__init__.py +67 -0
- zleap_parser/connector/sources/api_request_connector.py +202 -0
- zleap_parser/connector/sources/bigdb_connector.py +224 -0
- zleap_parser/connector/sources/core/__init__.py +55 -0
- zleap_parser/connector/sources/core/base.py +175 -0
- zleap_parser/connector/sources/core/builtin.py +45 -0
- zleap_parser/connector/sources/core/chat_source.py +87 -0
- zleap_parser/connector/sources/core/db_engine.py +901 -0
- zleap_parser/connector/sources/core/git_repository.py +1271 -0
- zleap_parser/connector/sources/core/registry.py +81 -0
- zleap_parser/connector/sources/core/wxwork_sdk/WeWorkFinanceSdk_C.h +152 -0
- zleap_parser/connector/sources/core/wxwork_sdk/libWeWorkFinanceSdk_C.so +0 -0
- zleap_parser/connector/sources/db_connector.py +177 -0
- zleap_parser/connector/sources/deep_crawl_connector.py +756 -0
- zleap_parser/connector/sources/feishu_bot_connector.py +474 -0
- zleap_parser/connector/sources/gitee_connector.py +116 -0
- zleap_parser/connector/sources/github_connector.py +116 -0
- zleap_parser/connector/sources/gitlab_connector.py +141 -0
- zleap_parser/connector/sources/rss_connector.py +209 -0
- zleap_parser/connector/sources/salesmartly_chat_connector.py +481 -0
- zleap_parser/connector/sources/web_search_connector.py +77 -0
- zleap_parser/connector/sources/wxwork_connector.py +562 -0
- zleap_parser/connector/url_cache.py +403 -0
- zleap_parser/downloader/__init__.py +316 -0
- zleap_parser/downloader/attachments.py +98 -0
- zleap_parser/downloader/http_client.py +184 -0
- zleap_parser/exceptions.py +48 -0
- zleap_parser/extract/__init__.py +8 -0
- zleap_parser/extract/octx_export.py +528 -0
- zleap_parser/extract/sag.py +594 -0
- zleap_parser/licenses/Apache-2.0.txt +200 -0
- zleap_parser/parser.py +504 -0
- zleap_parser/py.typed +0 -0
- zleap_parser/search.py +466 -0
- zleap_parser/url_collector/__init__.py +28 -0
- zleap_parser/url_collector/browser.py +558 -0
- zleap_parser/url_collector/collector.py +90 -0
- zleap_parser/url_collector/extractor.py +174 -0
- zleap_parser/url_collector/images.py +134 -0
- zleap_parser/url_collector/markdown.py +372 -0
- zleap_parser/url_collector/models.py +114 -0
- zleap_parser/url_collector/resource_cache.py +78 -0
- zleap_parser/url_collector/security.py +216 -0
- zleap_parser/url_collector/webcrawler/__init__.py +1 -0
- zleap_parser/url_collector/webcrawler/article_extractor.py +2025 -0
- zleap_parser/url_collector/webcrawler/http_client.py +35 -0
- zleap_parser/url_collector/webcrawler/models.py +404 -0
- zleap_parser/url_collector/webcrawler/proxy.py +66 -0
- zleap_parser/url_collector/webcrawler/resource_cache.py +129 -0
- zleap_parser/usage.py +176 -0
- zleap_parser/utils.py +328 -0
- zleap_parser-0.1.4.dist-info/METADATA +268 -0
- zleap_parser-0.1.4.dist-info/RECORD +98 -0
- zleap_parser-0.1.4.dist-info/WHEEL +4 -0
- zleap_parser-0.1.4.dist-info/licenses/LICENSE +21 -0
- 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.
|
zleap_parser/__init__.py
ADDED
|
@@ -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]
|