treesearchlib 1.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.
- treesearch/__init__.py +53 -0
- treesearch/__main__.py +6 -0
- treesearch/_bin/pst-extract.exe +0 -0
- treesearch/cli.py +554 -0
- treesearch/config.py +206 -0
- treesearch/fts.py +2293 -0
- treesearch/heuristics.py +425 -0
- treesearch/indexer.py +2038 -0
- treesearch/parsers/__init__.py +62 -0
- treesearch/parsers/anydoc_parser.py +193 -0
- treesearch/parsers/ast_parser.py +136 -0
- treesearch/parsers/docx_parser.py +304 -0
- treesearch/parsers/email_html_md.py +60 -0
- treesearch/parsers/excel_parser.py +218 -0
- treesearch/parsers/html_parser.py +172 -0
- treesearch/parsers/image_metadata.py +345 -0
- treesearch/parsers/image_parser.py +59 -0
- treesearch/parsers/image_store.py +182 -0
- treesearch/parsers/markitdown_parser.py +258 -0
- treesearch/parsers/mhtml_parser.py +108 -0
- treesearch/parsers/pdf_parser.py +409 -0
- treesearch/parsers/pst_attachment_store.py +156 -0
- treesearch/parsers/pst_parser.py +733 -0
- treesearch/parsers/registry.py +405 -0
- treesearch/parsers/treesitter_parser.py +433 -0
- treesearch/pathutil.py +227 -0
- treesearch/py.typed +0 -0
- treesearch/ripgrep.py +159 -0
- treesearch/search.py +935 -0
- treesearch/tokenizer.py +176 -0
- treesearch/tree.py +393 -0
- treesearch/tree_searcher.py +1006 -0
- treesearch/treesearch.py +574 -0
- treesearch/watch.py +305 -0
- treesearchlib-1.1.0.dist-info/METADATA +124 -0
- treesearchlib-1.1.0.dist-info/RECORD +39 -0
- treesearchlib-1.1.0.dist-info/WHEEL +5 -0
- treesearchlib-1.1.0.dist-info/entry_points.txt +2 -0
- treesearchlib-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@author:XuMing(xuming624@qq.com)
|
|
4
|
+
@description: Markitdown-based parser for TreeSearch.
|
|
5
|
+
|
|
6
|
+
Uses Microsoft's ``markitdown`` library to convert documents to Markdown,
|
|
7
|
+
then feeds the result into the existing ``md_to_tree`` pipeline.
|
|
8
|
+
|
|
9
|
+
Requires optional dependency: ``pip install markitdown``
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from typing import TYPE_CHECKING, Optional
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .image_store import ImagePart, ImageStore
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Extensions handled by markitdown
|
|
23
|
+
MARKITDOWN_EXTENSIONS = {".pptx"}
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Monkey-patch: python-pptx AutoShape.shape_type raises NotImplementedError
|
|
27
|
+
# for unrecognized shape types (e.g. SmartArt, connectors, ink). Returning
|
|
28
|
+
# None instead lets markitdown skip those shapes gracefully while still
|
|
29
|
+
# extracting text from shapes that have a text_frame.
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
def _patch_pptx_shape_type():
|
|
32
|
+
try:
|
|
33
|
+
from pptx.shapes.autoshape import Shape
|
|
34
|
+
except ImportError:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
_orig = Shape.shape_type.fget # type: ignore[attr-defined]
|
|
38
|
+
|
|
39
|
+
@property # type: ignore[misc]
|
|
40
|
+
def _safe_shape_type(self):
|
|
41
|
+
try:
|
|
42
|
+
return _orig(self)
|
|
43
|
+
except NotImplementedError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
Shape.shape_type = _safe_shape_type # type: ignore[assignment]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_patch_pptx_shape_type()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# pptx slide-level image extraction + injection helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
_RE_SLIDE_COMMENT = re.compile(r"^<!--\s*Slide number:\s*(\d+)\s*-->")
|
|
56
|
+
_RE_H1 = re.compile(r"^#\s+\S")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_pptx_slide_images(pptx_path: str) -> list[list["ImagePart"]]:
|
|
60
|
+
"""用 python-pptx 按 slide 顺序提取每 slide 的图片,返回 slide_parts[i]。
|
|
61
|
+
|
|
62
|
+
shape_type 取值可能抛 NotImplementedError(见 _patch_pptx_shape_type),
|
|
63
|
+
额外用 getattr 容错。
|
|
64
|
+
"""
|
|
65
|
+
from pptx import Presentation
|
|
66
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
67
|
+
|
|
68
|
+
from .image_store import ImagePart
|
|
69
|
+
|
|
70
|
+
prs = Presentation(pptx_path)
|
|
71
|
+
slide_parts: list[list[ImagePart]] = []
|
|
72
|
+
for idx, slide in enumerate(prs.slides):
|
|
73
|
+
parts: list[ImagePart] = []
|
|
74
|
+
for shape in slide.shapes:
|
|
75
|
+
try:
|
|
76
|
+
if shape.shape_type != MSO_SHAPE_TYPE.PICTURE:
|
|
77
|
+
continue
|
|
78
|
+
except (NotImplementedError, AttributeError):
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
img = shape.image
|
|
82
|
+
disp_w = (
|
|
83
|
+
round(shape.width / 9525)
|
|
84
|
+
if getattr(shape, "width", None) else None
|
|
85
|
+
)
|
|
86
|
+
parts.append(
|
|
87
|
+
ImagePart(
|
|
88
|
+
blob=img.blob,
|
|
89
|
+
ext=img.ext or "png",
|
|
90
|
+
source_ref=f"slide{idx}:{shape.shape_id}",
|
|
91
|
+
disp_w=disp_w,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
except Exception as e: # noqa: BLE001
|
|
95
|
+
logger.warning(
|
|
96
|
+
"skip pptx picture slide%d shape%s: %s",
|
|
97
|
+
idx,
|
|
98
|
+
getattr(shape, "shape_id", "?"),
|
|
99
|
+
e,
|
|
100
|
+
)
|
|
101
|
+
slide_parts.append(parts)
|
|
102
|
+
return slide_parts
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _inject_slide_images(md: str, slide_mds: list[str]) -> str:
|
|
106
|
+
"""按 slide 边界把每 slide 的图片 md 注入到对应 slide 块尾。
|
|
107
|
+
|
|
108
|
+
优先按 ``<!-- Slide number: N -->`` 切分;否则按 H1 (``# ``) 切分;
|
|
109
|
+
若切分块数 != slide 数,降级为全部图片追加到末尾。
|
|
110
|
+
|
|
111
|
+
注意:comment 和 H1 边界不混合检测 —— markitdown 对同一 slide 同时输出
|
|
112
|
+
``<!-- Slide number: N -->`` 和 ``# 标题``,混合会重复计数导致注入到
|
|
113
|
+
H1 之前(变成被丢弃的前导文本)。改为分别收集,优先匹配 comment。
|
|
114
|
+
"""
|
|
115
|
+
n = len(slide_mds)
|
|
116
|
+
if n == 0 or not any(slide_mds):
|
|
117
|
+
return md
|
|
118
|
+
|
|
119
|
+
lines = md.split("\n")
|
|
120
|
+
|
|
121
|
+
comment_bounds = [
|
|
122
|
+
i for i, ln in enumerate(lines) if _RE_SLIDE_COMMENT.match(ln.strip())
|
|
123
|
+
]
|
|
124
|
+
h1_bounds = [
|
|
125
|
+
i for i, ln in enumerate(lines) if _RE_H1.match(ln.strip())
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
boundaries: list[int] = []
|
|
129
|
+
if len(comment_bounds) == n:
|
|
130
|
+
boundaries = comment_bounds
|
|
131
|
+
elif len(h1_bounds) == n:
|
|
132
|
+
boundaries = h1_bounds
|
|
133
|
+
|
|
134
|
+
if boundaries:
|
|
135
|
+
# 每个 slide 块尾 = 下一边界行(或文档末尾);在该位置前插入图片 md
|
|
136
|
+
block_ends = boundaries[1:] + [len(lines)]
|
|
137
|
+
out = list(lines)
|
|
138
|
+
offset = 0
|
|
139
|
+
for i in range(n):
|
|
140
|
+
if not slide_mds[i]:
|
|
141
|
+
continue
|
|
142
|
+
insert_at = block_ends[i] + offset
|
|
143
|
+
block = ["", slide_mds[i], ""]
|
|
144
|
+
out[insert_at:insert_at] = block
|
|
145
|
+
offset += len(block)
|
|
146
|
+
return "\n".join(out)
|
|
147
|
+
|
|
148
|
+
# 降级:全部追加末尾
|
|
149
|
+
tail = "\n\n".join(m for m in slide_mds if m)
|
|
150
|
+
return md.rstrip() + "\n\n" + tail
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def markitdown_to_tree(
|
|
154
|
+
file_path: str,
|
|
155
|
+
*,
|
|
156
|
+
model: Optional[str] = None,
|
|
157
|
+
if_add_node_summary: bool = True,
|
|
158
|
+
summary_chars_threshold: int = 600,
|
|
159
|
+
if_add_doc_description: bool = False,
|
|
160
|
+
if_add_node_text: bool = False,
|
|
161
|
+
if_add_node_id: bool = True,
|
|
162
|
+
image_store: "ImageStore | None" = None,
|
|
163
|
+
rel_path: str | None = None,
|
|
164
|
+
**kwargs,
|
|
165
|
+
) -> dict:
|
|
166
|
+
"""Build a tree index from a document via markitdown.
|
|
167
|
+
|
|
168
|
+
Converts the document to Markdown using ``markitdown``, then delegates
|
|
169
|
+
to ``md_to_tree`` for structure extraction.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
{'doc_name': str, 'structure': list, 'source_path': str}
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
from markitdown import MarkItDown
|
|
176
|
+
except ImportError:
|
|
177
|
+
raise ImportError(
|
|
178
|
+
"Markitdown support requires 'markitdown'. "
|
|
179
|
+
"Install with: pip install markitdown"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
doc_name = os.path.splitext(os.path.basename(file_path))[0]
|
|
183
|
+
logger.debug("Parsing with markitdown: %s", file_path)
|
|
184
|
+
|
|
185
|
+
md = MarkItDown()
|
|
186
|
+
result = md.convert(file_path)
|
|
187
|
+
md_content = result.text_content
|
|
188
|
+
|
|
189
|
+
if not md_content or not md_content.strip():
|
|
190
|
+
logger.warning("markitdown returned empty content for: %s", file_path)
|
|
191
|
+
md_content = ""
|
|
192
|
+
|
|
193
|
+
# pptx 图片提取 + slide 级注入(仅 .pptx;其他 markitdown 支持类型暂不提图)
|
|
194
|
+
if image_store is not None and rel_path and file_path.lower().endswith(".pptx"):
|
|
195
|
+
try:
|
|
196
|
+
slide_parts = _extract_pptx_slide_images(file_path)
|
|
197
|
+
all_parts = [p for parts in slide_parts for p in parts]
|
|
198
|
+
refs = (
|
|
199
|
+
image_store.extract_for_doc(rel_path, all_parts)
|
|
200
|
+
if all_parts
|
|
201
|
+
else {}
|
|
202
|
+
)
|
|
203
|
+
slide_mds: list[str] = []
|
|
204
|
+
for idx, parts in enumerate(slide_parts):
|
|
205
|
+
mds = [
|
|
206
|
+
refs[p.source_ref].inline_md
|
|
207
|
+
for p in parts
|
|
208
|
+
if p.source_ref in refs
|
|
209
|
+
]
|
|
210
|
+
slide_mds.append(" ".join(mds) if mds else "")
|
|
211
|
+
md_content = re.sub(r"!\[[^\]]*\]\(Picture[^)]+\)", "", md_content)
|
|
212
|
+
md_content = _inject_slide_images(md_content, slide_mds)
|
|
213
|
+
except ImportError:
|
|
214
|
+
logger.debug("python-pptx not available, skip pptx image extraction")
|
|
215
|
+
except Exception as e: # noqa: BLE001
|
|
216
|
+
logger.warning("pptx image extraction failed for %s: %s", file_path, e)
|
|
217
|
+
|
|
218
|
+
from ..indexer import md_to_tree
|
|
219
|
+
|
|
220
|
+
tree_result = await md_to_tree(
|
|
221
|
+
md_content=md_content,
|
|
222
|
+
model=model,
|
|
223
|
+
if_add_node_summary=if_add_node_summary,
|
|
224
|
+
summary_chars_threshold=summary_chars_threshold,
|
|
225
|
+
if_add_doc_description=if_add_doc_description,
|
|
226
|
+
if_add_node_text=if_add_node_text,
|
|
227
|
+
if_add_node_id=if_add_node_id,
|
|
228
|
+
**kwargs,
|
|
229
|
+
)
|
|
230
|
+
tree_result["doc_name"] = doc_name
|
|
231
|
+
tree_result["source_path"] = os.path.abspath(file_path)
|
|
232
|
+
tree_result["source_type"] = "pptx"
|
|
233
|
+
|
|
234
|
+
# PPTX 特殊处理:将扁平的 slide 节点包裹在文档根节点下,形成两层结构
|
|
235
|
+
# markitdown 输出每个 slide 为 # 标题,md_to_tree 将其作为顶层节点
|
|
236
|
+
# 需要聚合为一个根节点 → 多个子节点(每页一个)的层次结构
|
|
237
|
+
structure = tree_result.get("structure", [])
|
|
238
|
+
if len(structure) > 1:
|
|
239
|
+
# 计算整体行号范围
|
|
240
|
+
min_line = min(
|
|
241
|
+
(n.get("line_start", 0) for n in structure if n.get("line_start") is not None),
|
|
242
|
+
default=0,
|
|
243
|
+
)
|
|
244
|
+
max_line = max(
|
|
245
|
+
(n.get("line_end", 0) for n in structure if n.get("line_end") is not None),
|
|
246
|
+
default=0,
|
|
247
|
+
)
|
|
248
|
+
root_node = {
|
|
249
|
+
"title": doc_name,
|
|
250
|
+
"node_id": "0",
|
|
251
|
+
"text": "",
|
|
252
|
+
"line_start": min_line,
|
|
253
|
+
"line_end": max_line,
|
|
254
|
+
"nodes": structure,
|
|
255
|
+
}
|
|
256
|
+
tree_result["structure"] = [root_node]
|
|
257
|
+
|
|
258
|
+
return tree_result
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""MHTML (MIME HTML) parser for TreeSearch.
|
|
3
|
+
|
|
4
|
+
MHTML (.mhtml/.mht) is a MIME multipart archive that packages a web page
|
|
5
|
+
together with its embedded resources (images, css...). The main payload is
|
|
6
|
+
the ``text/html`` part, usually quoted-printable or base64 encoded.
|
|
7
|
+
|
|
8
|
+
Strategy: unpack with the standard-library ``email`` module, pick the main
|
|
9
|
+
``text/html`` part, decode it, then reuse the HTML tree builder
|
|
10
|
+
(BeautifulSoup based, fidelity-first) from ``html_parser``.
|
|
11
|
+
|
|
12
|
+
No optional dependencies beyond beautifulsoup4 (same as html_parser).
|
|
13
|
+
"""
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from email import policy
|
|
17
|
+
from email.parser import BytesParser
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .html_parser import html_content_to_tree
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _extract_main_html(mhtml_bytes: bytes) -> Optional[str]:
|
|
26
|
+
"""Unpack an MHTML archive and return the decoded main HTML part.
|
|
27
|
+
|
|
28
|
+
Returns None if no usable text/html part is found.
|
|
29
|
+
"""
|
|
30
|
+
msg = BytesParser(policy=policy.default).parsebytes(mhtml_bytes)
|
|
31
|
+
|
|
32
|
+
candidates = []
|
|
33
|
+
if msg.is_multipart():
|
|
34
|
+
for part in msg.walk():
|
|
35
|
+
if part.get_content_type() == "text/html":
|
|
36
|
+
candidates.append(part)
|
|
37
|
+
elif msg.get_content_type() == "text/html":
|
|
38
|
+
candidates.append(msg)
|
|
39
|
+
|
|
40
|
+
if not candidates:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
# Prefer the part explicitly marked as the start/root of the archive,
|
|
44
|
+
# otherwise take the first text/html part.
|
|
45
|
+
start = msg.get("start", "").strip("<>")
|
|
46
|
+
main = None
|
|
47
|
+
if start:
|
|
48
|
+
for part in candidates:
|
|
49
|
+
if part.get("Content-ID", "").strip("<>") == start:
|
|
50
|
+
main = part
|
|
51
|
+
break
|
|
52
|
+
if main is None:
|
|
53
|
+
main = candidates[0]
|
|
54
|
+
|
|
55
|
+
payload = main.get_payload(decode=True)
|
|
56
|
+
if payload is None:
|
|
57
|
+
try:
|
|
58
|
+
return main.get_content()
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
charset = main.get_content_charset() or "utf-8"
|
|
62
|
+
try:
|
|
63
|
+
return payload.decode(charset, errors="replace")
|
|
64
|
+
except (LookupError, ValueError):
|
|
65
|
+
logger.debug("Unknown charset %r in MHTML part, fallback to utf-8", charset)
|
|
66
|
+
return payload.decode("utf-8", errors="replace")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def mhtml_to_tree(
|
|
70
|
+
mhtml_path: str,
|
|
71
|
+
*,
|
|
72
|
+
model: Optional[str] = None,
|
|
73
|
+
if_add_node_summary: bool = True,
|
|
74
|
+
summary_chars_threshold: int = 600,
|
|
75
|
+
if_add_doc_description: bool = False,
|
|
76
|
+
if_add_node_text: bool = False,
|
|
77
|
+
if_add_node_id: bool = True,
|
|
78
|
+
**kwargs,
|
|
79
|
+
) -> dict:
|
|
80
|
+
"""Build a tree index from an MHTML web archive.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
{'doc_name': str, 'structure': list, 'source_path': str}
|
|
84
|
+
"""
|
|
85
|
+
doc_name = os.path.splitext(os.path.basename(mhtml_path))[0]
|
|
86
|
+
logger.debug("Parsing MHTML: %s", mhtml_path)
|
|
87
|
+
|
|
88
|
+
with open(mhtml_path, "rb") as f:
|
|
89
|
+
mhtml_bytes = f.read()
|
|
90
|
+
|
|
91
|
+
html_content = _extract_main_html(mhtml_bytes)
|
|
92
|
+
if not html_content or not html_content.strip():
|
|
93
|
+
logger.warning("No text/html part found in MHTML: %s", mhtml_path)
|
|
94
|
+
html_content = ""
|
|
95
|
+
|
|
96
|
+
return await html_content_to_tree(
|
|
97
|
+
html_content,
|
|
98
|
+
doc_name=doc_name,
|
|
99
|
+
source_path=os.path.abspath(mhtml_path),
|
|
100
|
+
source_type="html",
|
|
101
|
+
model=model,
|
|
102
|
+
if_add_node_summary=if_add_node_summary,
|
|
103
|
+
summary_chars_threshold=summary_chars_threshold,
|
|
104
|
+
if_add_doc_description=if_add_doc_description,
|
|
105
|
+
if_add_node_text=if_add_node_text,
|
|
106
|
+
if_add_node_id=if_add_node_id,
|
|
107
|
+
**kwargs,
|
|
108
|
+
)
|