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.
Files changed (39) hide show
  1. treesearch/__init__.py +53 -0
  2. treesearch/__main__.py +6 -0
  3. treesearch/_bin/pst-extract.exe +0 -0
  4. treesearch/cli.py +554 -0
  5. treesearch/config.py +206 -0
  6. treesearch/fts.py +2293 -0
  7. treesearch/heuristics.py +425 -0
  8. treesearch/indexer.py +2038 -0
  9. treesearch/parsers/__init__.py +62 -0
  10. treesearch/parsers/anydoc_parser.py +193 -0
  11. treesearch/parsers/ast_parser.py +136 -0
  12. treesearch/parsers/docx_parser.py +304 -0
  13. treesearch/parsers/email_html_md.py +60 -0
  14. treesearch/parsers/excel_parser.py +218 -0
  15. treesearch/parsers/html_parser.py +172 -0
  16. treesearch/parsers/image_metadata.py +345 -0
  17. treesearch/parsers/image_parser.py +59 -0
  18. treesearch/parsers/image_store.py +182 -0
  19. treesearch/parsers/markitdown_parser.py +258 -0
  20. treesearch/parsers/mhtml_parser.py +108 -0
  21. treesearch/parsers/pdf_parser.py +409 -0
  22. treesearch/parsers/pst_attachment_store.py +156 -0
  23. treesearch/parsers/pst_parser.py +733 -0
  24. treesearch/parsers/registry.py +405 -0
  25. treesearch/parsers/treesitter_parser.py +433 -0
  26. treesearch/pathutil.py +227 -0
  27. treesearch/py.typed +0 -0
  28. treesearch/ripgrep.py +159 -0
  29. treesearch/search.py +935 -0
  30. treesearch/tokenizer.py +176 -0
  31. treesearch/tree.py +393 -0
  32. treesearch/tree_searcher.py +1006 -0
  33. treesearch/treesearch.py +574 -0
  34. treesearch/watch.py +305 -0
  35. treesearchlib-1.1.0.dist-info/METADATA +124 -0
  36. treesearchlib-1.1.0.dist-info/RECORD +39 -0
  37. treesearchlib-1.1.0.dist-info/WHEEL +5 -0
  38. treesearchlib-1.1.0.dist-info/entry_points.txt +2 -0
  39. 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
+ )