deeptutor-knowledge 1.7.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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""HTML-to-markdown extraction for documentation sites.
|
|
2
|
+
|
|
3
|
+
Strips navigation chrome, sidebars, and boilerplate, then converts the
|
|
4
|
+
main article content to clean markdown preserving structure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html as _html
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from urllib.parse import urljoin, urlparse
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Tags to completely remove before extraction
|
|
17
|
+
_STRIP_TAGS = (
|
|
18
|
+
"script",
|
|
19
|
+
"style",
|
|
20
|
+
"noscript",
|
|
21
|
+
"nav",
|
|
22
|
+
"header",
|
|
23
|
+
"footer",
|
|
24
|
+
"aside",
|
|
25
|
+
"iframe",
|
|
26
|
+
"svg",
|
|
27
|
+
"form",
|
|
28
|
+
"button",
|
|
29
|
+
"input",
|
|
30
|
+
"select",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# CSS-ish selectors (via XPath) that nominate possible article roots. They are
|
|
34
|
+
# scored rather than treated as a first-match list: pages commonly contain an
|
|
35
|
+
# ``article`` teaser before their real ``main``, while article bodies are often
|
|
36
|
+
# nested inside a broader semantic container.
|
|
37
|
+
_CONTENT_XPATHS = [
|
|
38
|
+
"//*[@itemprop='articleBody']",
|
|
39
|
+
"//article",
|
|
40
|
+
"//div[contains(@class, 'sl-markdown')]",
|
|
41
|
+
"//div[contains(@class, 'theme-doc-markdown')]",
|
|
42
|
+
"//div[contains(@class, 'markdown-body')]",
|
|
43
|
+
"//div[contains(@class, 'md-content')]",
|
|
44
|
+
"//*[@role='main']",
|
|
45
|
+
"//main",
|
|
46
|
+
"//*[@id='main-content']",
|
|
47
|
+
"//div[contains(@class, 'content')]",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Exact class/id components that normally identify navigation or page chrome.
|
|
51
|
+
# Splitting names on punctuation is deliberate: a raw substring check for
|
|
52
|
+
# ``header``/``toc`` also matches unrelated implementation names and can detach
|
|
53
|
+
# the article itself before content selection has a chance to see it.
|
|
54
|
+
_CHROME_NAME_PARTS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"backtotop",
|
|
57
|
+
"breadcrumb",
|
|
58
|
+
"breadcrumbs",
|
|
59
|
+
"editsection",
|
|
60
|
+
"editthispage",
|
|
61
|
+
"footer",
|
|
62
|
+
"lastupdated",
|
|
63
|
+
"leftsidebar",
|
|
64
|
+
"menu",
|
|
65
|
+
"mobileheader",
|
|
66
|
+
"navbar",
|
|
67
|
+
"navbox",
|
|
68
|
+
"navigation",
|
|
69
|
+
"pagination",
|
|
70
|
+
"printfooter",
|
|
71
|
+
"rightsidebar",
|
|
72
|
+
"search",
|
|
73
|
+
"share",
|
|
74
|
+
"sharing",
|
|
75
|
+
"sidebar",
|
|
76
|
+
"siteheader",
|
|
77
|
+
"skiptocontent",
|
|
78
|
+
"skiplink",
|
|
79
|
+
"social",
|
|
80
|
+
"socialicons",
|
|
81
|
+
"sronly",
|
|
82
|
+
"tableofcontents",
|
|
83
|
+
"themetoggle",
|
|
84
|
+
"toc",
|
|
85
|
+
"toolbar",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
_CHROME_ROLES = frozenset({"banner", "complementary", "contentinfo", "navigation", "search"})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Navigation extraction ────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
# XPath selectors for sidebar / navigation containers, in priority order.
|
|
94
|
+
# Checked before _STRIP_TAGS removes them.
|
|
95
|
+
_SIDEBAR_XPATHS = [
|
|
96
|
+
# Docusaurus / Starlight
|
|
97
|
+
"//nav[contains(@class, 'theme-doc-sidebar-menu')]",
|
|
98
|
+
"//div[contains(@class, 'theme-doc-sidebar-container')]//nav",
|
|
99
|
+
"//nav[contains(@class, 'sidebar')]//nav",
|
|
100
|
+
# MkDocs
|
|
101
|
+
"//div[contains(@class, 'md-sidebar--primary')]//nav",
|
|
102
|
+
"//nav[contains(@class, 'md-nav--primary')]",
|
|
103
|
+
# GitBook
|
|
104
|
+
"//div[contains(@class, 'book-summary')]",
|
|
105
|
+
"//nav[contains(@class, 'navigation-sidebar')]",
|
|
106
|
+
# ReadTheDocs / Sphinx
|
|
107
|
+
"//div[contains(@class, 'wy-nav-side')]//ul",
|
|
108
|
+
"//div[contains(@class, 'sphinxsidebar')]",
|
|
109
|
+
# VuePress / VitePress
|
|
110
|
+
"//div[contains(@class, 'sidebar')]//nav",
|
|
111
|
+
# Generic fallbacks (nav before aside — aside often matches right-side TOC)
|
|
112
|
+
"//nav[contains(@class, 'sidebar')]",
|
|
113
|
+
"//aside[contains(@class, 'sidebar')]",
|
|
114
|
+
"//div[contains(@class, 'toc-tree')]",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Tags whose subtrees _walk should NOT descend into. Any element not in
|
|
119
|
+
# this set gets recursed into, catching custom-element components from
|
|
120
|
+
# Astro/Starlight, web-components, etc. that a static tag-allowlist would miss.
|
|
121
|
+
_WALK_SKIP_TAGS = frozenset(
|
|
122
|
+
{
|
|
123
|
+
"script",
|
|
124
|
+
"style",
|
|
125
|
+
"svg",
|
|
126
|
+
"input",
|
|
127
|
+
"button",
|
|
128
|
+
"form",
|
|
129
|
+
"meta",
|
|
130
|
+
"link",
|
|
131
|
+
"br",
|
|
132
|
+
"hr",
|
|
133
|
+
"img",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def extract_navigation(raw_html: str, base_url: str) -> list[dict]:
|
|
139
|
+
"""Extract navigation links from a doc-site page sidebar.
|
|
140
|
+
|
|
141
|
+
Returns a flat ordered list of ``{title, url, path, depth}`` dicts,
|
|
142
|
+
preserving the sidebar's visual order and nesting depth. Falls back
|
|
143
|
+
to an empty list when no sidebar container is found.
|
|
144
|
+
|
|
145
|
+
Must be called on the **raw** HTML (before :func:`extract_article_markdown`
|
|
146
|
+
strips ``nav``/``aside`` elements).
|
|
147
|
+
"""
|
|
148
|
+
from urllib.parse import urljoin, urlparse
|
|
149
|
+
|
|
150
|
+
from lxml import html as lxml_html # nosec B410 - HTML parser, not XML
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
tree = lxml_html.fromstring(raw_html)
|
|
154
|
+
except Exception:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
# Try each selector until we extract enough links from one.
|
|
158
|
+
# A selector might match the wrong container (e.g. right-side TOC)
|
|
159
|
+
# and yield no usable links — keep trying the next one.
|
|
160
|
+
links: list[dict] = []
|
|
161
|
+
seen: set[str] = set()
|
|
162
|
+
|
|
163
|
+
def _walk(el, depth: int):
|
|
164
|
+
"""Recursively walk sidebar DOM, emitting links with depth info."""
|
|
165
|
+
for child in el:
|
|
166
|
+
tag = _tag_name(child)
|
|
167
|
+
if not tag:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
# Anchor: emit a navigation entry.
|
|
171
|
+
if tag == "a":
|
|
172
|
+
href = (child.get("href") or "").strip()
|
|
173
|
+
title = child.text_content().strip()
|
|
174
|
+
if not href or not title or href.startswith("#"):
|
|
175
|
+
_walk(child, depth)
|
|
176
|
+
continue
|
|
177
|
+
lower = href.lower()
|
|
178
|
+
if lower.startswith(("javascript:", "mailto:", "tel:", "data:")):
|
|
179
|
+
continue
|
|
180
|
+
absolute = urljoin(base_url, href.split("#")[0])
|
|
181
|
+
parsed = urlparse(absolute)
|
|
182
|
+
if parsed.scheme.lower() not in ("http", "https"):
|
|
183
|
+
continue
|
|
184
|
+
if absolute in seen:
|
|
185
|
+
continue
|
|
186
|
+
seen.add(absolute)
|
|
187
|
+
links.append(
|
|
188
|
+
{
|
|
189
|
+
"title": title,
|
|
190
|
+
"url": absolute,
|
|
191
|
+
"path": parsed.path,
|
|
192
|
+
"depth": depth,
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
elif tag in ("ul", "ol"):
|
|
197
|
+
_walk(child, depth + 1)
|
|
198
|
+
elif tag not in _WALK_SKIP_TAGS:
|
|
199
|
+
# Descend into any container we don't explicitly skip.
|
|
200
|
+
# This catches custom-element components (Astro/Starlight,
|
|
201
|
+
# web-components) that would be missed by a static tag list.
|
|
202
|
+
_walk(child, depth)
|
|
203
|
+
|
|
204
|
+
for xp in _SIDEBAR_XPATHS:
|
|
205
|
+
found = tree.xpath(xp)
|
|
206
|
+
if not found:
|
|
207
|
+
continue
|
|
208
|
+
# Walk the first matched element.
|
|
209
|
+
links.clear()
|
|
210
|
+
seen.clear()
|
|
211
|
+
_walk(found[0], -1)
|
|
212
|
+
if len(links) >= 2:
|
|
213
|
+
break # got a real sidebar
|
|
214
|
+
|
|
215
|
+
if len(links) < 2:
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
# Normalize depths so the shallowest link is at depth 0.
|
|
219
|
+
min_depth = min(lnk["depth"] for lnk in links)
|
|
220
|
+
if min_depth > 0:
|
|
221
|
+
for lnk in links:
|
|
222
|
+
lnk["depth"] -= min_depth
|
|
223
|
+
|
|
224
|
+
return links
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def extract_headings(markdown: str) -> list[dict]:
|
|
228
|
+
"""Extract ATX-style headings from markdown text.
|
|
229
|
+
|
|
230
|
+
Returns a list of ``{level, text, slug}`` dicts. Code-fence aware:
|
|
231
|
+
``#`` characters inside fenced blocks are ignored.
|
|
232
|
+
|
|
233
|
+
Used for the current-page table of contents.
|
|
234
|
+
"""
|
|
235
|
+
headings: list[dict] = []
|
|
236
|
+
in_fence = False
|
|
237
|
+
|
|
238
|
+
for line in markdown.split("\n"):
|
|
239
|
+
stripped = line.lstrip()
|
|
240
|
+
if stripped.startswith("```"):
|
|
241
|
+
in_fence = not in_fence
|
|
242
|
+
continue
|
|
243
|
+
if in_fence:
|
|
244
|
+
continue
|
|
245
|
+
m = re.match(r"^(#{1,6})\s+(.+)$", line)
|
|
246
|
+
if m:
|
|
247
|
+
level = len(m.group(1))
|
|
248
|
+
text = m.group(2).strip()
|
|
249
|
+
# Remove trailing markdown (links, formatting)
|
|
250
|
+
clean = re.sub(r"\[([^]]*)\]\([^)]*\)", r"\1", text)
|
|
251
|
+
clean = re.sub(r"[*`_~]", "", clean).strip()
|
|
252
|
+
slug = re.sub(r"[^a-z0-9\s-]", "", clean.lower())
|
|
253
|
+
slug = re.sub(r"\s+", "-", slug).strip("-")
|
|
254
|
+
headings.append({"level": level, "text": clean, "slug": slug})
|
|
255
|
+
|
|
256
|
+
return headings
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _tag_name(el) -> str:
|
|
260
|
+
"""Return a lower-case HTML tag, or ``""`` for comments/PI nodes.
|
|
261
|
+
|
|
262
|
+
lxml represents comments with a callable sentinel in ``node.tag``. Treating
|
|
263
|
+
it as a string raises during conversion; callers then fell back to a regex
|
|
264
|
+
text dump that flattened every heading and retained the whole page chrome.
|
|
265
|
+
"""
|
|
266
|
+
tag = getattr(el, "tag", "")
|
|
267
|
+
return tag.lower() if isinstance(tag, str) else ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _content_score(el) -> tuple[int, int, int]:
|
|
271
|
+
"""Rank a possible article root by prose, structure, then total text."""
|
|
272
|
+
paragraphs = el.xpath(".//p")
|
|
273
|
+
prose_chars = sum(len(re.sub(r"\s+", " ", row.text_content()).strip()) for row in paragraphs)
|
|
274
|
+
heading_count = len(el.xpath(".//h1 | .//h2 | .//h3 | .//h4 | .//h5 | .//h6"))
|
|
275
|
+
text_chars = len(re.sub(r"\s+", " ", el.text_content()).strip())
|
|
276
|
+
# Paragraph prose is a stronger article signal than a link-heavy menu. A
|
|
277
|
+
# heading bonus keeps documentation pages useful even when their content is
|
|
278
|
+
# mostly lists and code rather than conventional paragraphs.
|
|
279
|
+
return (prose_chars * 4 + heading_count * 400 + text_chars, prose_chars, heading_count)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _select_content_element(tree):
|
|
283
|
+
"""Choose the strongest semantic/content candidate from the document."""
|
|
284
|
+
candidates = []
|
|
285
|
+
seen: set[int] = set()
|
|
286
|
+
for xpath in _CONTENT_XPATHS:
|
|
287
|
+
for el in tree.xpath(xpath):
|
|
288
|
+
identity = id(el)
|
|
289
|
+
if identity in seen:
|
|
290
|
+
continue
|
|
291
|
+
seen.add(identity)
|
|
292
|
+
candidates.append(el)
|
|
293
|
+
if candidates:
|
|
294
|
+
return max(candidates, key=_content_score)
|
|
295
|
+
body = tree.xpath("//body")
|
|
296
|
+
return body[0] if body else tree
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _looks_like_chrome(el) -> bool:
|
|
300
|
+
"""Whether an element is labelled as navigation or surrounding chrome."""
|
|
301
|
+
role = str(el.get("role") or "").strip().lower()
|
|
302
|
+
if role in _CHROME_ROLES:
|
|
303
|
+
return True
|
|
304
|
+
names = f"{el.get('class') or ''} {el.get('id') or ''}".lower()
|
|
305
|
+
parts = set(re.findall(r"[a-z0-9]+", names))
|
|
306
|
+
# Keep both components (``sidebar`` in ``docs-sidebar``) and collapsed CSS
|
|
307
|
+
# names (``backtotop`` in ``back-to-top``). Exact matching at both levels
|
|
308
|
+
# avoids the destructive false positives caused by raw substring checks.
|
|
309
|
+
parts.update(re.sub(r"[^a-z0-9]", "", name) for name in names.split())
|
|
310
|
+
return bool(parts & _CHROME_NAME_PARTS)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _remove_element(el) -> None:
|
|
314
|
+
"""Detach an lxml element when it still has a parent."""
|
|
315
|
+
parent = el.getparent()
|
|
316
|
+
if parent is not None:
|
|
317
|
+
parent.remove(el)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def extract_article_markdown(raw_html: str, base_url: str = "") -> tuple[str, str]:
|
|
321
|
+
"""Extract ``(title, markdown)`` from a doc-site HTML page.
|
|
322
|
+
|
|
323
|
+
Falls back to full-body text if no article container is found, but
|
|
324
|
+
always strips navigation, scripts, and other boilerplate first.
|
|
325
|
+
"""
|
|
326
|
+
from lxml import html as lxml_html # nosec B410 - HTML parser, not XML
|
|
327
|
+
|
|
328
|
+
title = ""
|
|
329
|
+
try:
|
|
330
|
+
tree = lxml_html.fromstring(raw_html)
|
|
331
|
+
except Exception:
|
|
332
|
+
# Malformed HTML — fall back to regex title extraction
|
|
333
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", raw_html, re.I | re.S)
|
|
334
|
+
if m:
|
|
335
|
+
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
336
|
+
# Crude strip of tags
|
|
337
|
+
text = re.sub(r"<[^>]+>", " ", raw_html)
|
|
338
|
+
text = _html.unescape(text)
|
|
339
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
340
|
+
return title, text
|
|
341
|
+
|
|
342
|
+
# Title from <title> tag or first <h1>
|
|
343
|
+
title_els = tree.xpath("//title/text()")
|
|
344
|
+
if title_els:
|
|
345
|
+
title = re.sub(r"\s+", " ", title_els[0]).strip()
|
|
346
|
+
# Strip site suffix like " | DeepTutor"
|
|
347
|
+
title = re.sub(r"\s*[||]\s*[^|]+$", "", title).strip()
|
|
348
|
+
|
|
349
|
+
# Select before pruning. Cleanup selectors are necessarily heuristic, and
|
|
350
|
+
# mutating the whole document first can detach the only useful root because
|
|
351
|
+
# a framework happened to use a chrome-like word in an ancestor class.
|
|
352
|
+
content_el = _select_content_element(tree)
|
|
353
|
+
|
|
354
|
+
# Remove boilerplate elements within the selected article only. Site-wide
|
|
355
|
+
# navigation is normally outside this root; these rules handle embedded
|
|
356
|
+
# tables of contents, sharing toolbars, and footer/navigation widgets.
|
|
357
|
+
for tag in _STRIP_TAGS:
|
|
358
|
+
for el in content_el.xpath(f".//{tag}"):
|
|
359
|
+
_remove_element(el)
|
|
360
|
+
|
|
361
|
+
# Remove aria-hidden elements (decorative, screen-reader text)
|
|
362
|
+
for el in content_el.xpath(".//*[@aria-hidden='true']"):
|
|
363
|
+
_remove_element(el)
|
|
364
|
+
|
|
365
|
+
for el in content_el.xpath(".//*"):
|
|
366
|
+
if _looks_like_chrome(el):
|
|
367
|
+
_remove_element(el)
|
|
368
|
+
|
|
369
|
+
# Convert to markdown
|
|
370
|
+
md = _element_to_markdown(content_el, base_url=base_url)
|
|
371
|
+
md = _clean_markdown(md)
|
|
372
|
+
|
|
373
|
+
if not title:
|
|
374
|
+
h1 = content_el.xpath(".//h1/text()")
|
|
375
|
+
if h1:
|
|
376
|
+
title = h1[0].strip()
|
|
377
|
+
|
|
378
|
+
if title and not md.lstrip().startswith("#"):
|
|
379
|
+
md = f"# {title}\n\n{md}"
|
|
380
|
+
|
|
381
|
+
return title, md
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _pre_to_text(el) -> str:
|
|
385
|
+
"""Extract text from a <pre> element, preserving code line structure.
|
|
386
|
+
|
|
387
|
+
Modern doc-site code blocks (Expressive Code, Shiki, Prism) wrap each
|
|
388
|
+
line in a ``<div class="ec-line">``, ``<span class="line">``, or similar
|
|
389
|
+
container with *no* inter-line whitespace. ``text_content()`` would
|
|
390
|
+
mash everything onto one line. This helper detects those wrappers and
|
|
391
|
+
inserts real newlines.
|
|
392
|
+
"""
|
|
393
|
+
# Expressive Code / Starlight: <div class="ec-line"><div class="code">...
|
|
394
|
+
line_els = el.xpath(
|
|
395
|
+
".//div[contains(@class, 'ec-line')]"
|
|
396
|
+
" | .//div[contains(@class, 'code-line')]"
|
|
397
|
+
" | .//span[contains(@class, 'line')]"
|
|
398
|
+
" | .//div[contains(@class, 'code-line')]"
|
|
399
|
+
)
|
|
400
|
+
if line_els:
|
|
401
|
+
return "\n".join(le.text_content() for le in line_els)
|
|
402
|
+
|
|
403
|
+
# Fallback: walk all children, converting <br> to newlines.
|
|
404
|
+
parts: list[str] = []
|
|
405
|
+
if el.text:
|
|
406
|
+
parts.append(el.text)
|
|
407
|
+
for node in el.iter():
|
|
408
|
+
tag = _tag_name(node)
|
|
409
|
+
if not tag:
|
|
410
|
+
continue
|
|
411
|
+
if tag == "br":
|
|
412
|
+
parts.append("\n")
|
|
413
|
+
elif node.text:
|
|
414
|
+
parts.append(node.text)
|
|
415
|
+
if node.tail:
|
|
416
|
+
parts.append(node.tail)
|
|
417
|
+
return "".join(parts)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _element_to_markdown(el, base_url: str = "") -> str:
|
|
421
|
+
"""Recursively convert an lxml element to markdown text.
|
|
422
|
+
|
|
423
|
+
Correctly preserves inter-element whitespace by including both
|
|
424
|
+
``el.text`` (text before the first child) and each ``child.tail``
|
|
425
|
+
(text after a child element). This fixes the missing-space bug where
|
|
426
|
+
inline markup like ``word<strong>bold</strong>word`` collapsed into
|
|
427
|
+
``word**bold**word``.
|
|
428
|
+
"""
|
|
429
|
+
parts: list[str] = []
|
|
430
|
+
|
|
431
|
+
# Text before the first child element (el.text).
|
|
432
|
+
if el.text:
|
|
433
|
+
parts.append(el.text)
|
|
434
|
+
|
|
435
|
+
for child in el:
|
|
436
|
+
tag = _tag_name(child)
|
|
437
|
+
if not tag:
|
|
438
|
+
# Comments and processing instructions are metadata, not readable
|
|
439
|
+
# prose. Their tail can contain real text and must still survive.
|
|
440
|
+
if child.tail:
|
|
441
|
+
parts.append(child.tail)
|
|
442
|
+
continue
|
|
443
|
+
if tag in _STRIP_TAGS:
|
|
444
|
+
# Still need to preserve the tail of a stripped element.
|
|
445
|
+
if child.tail:
|
|
446
|
+
parts.append(child.tail)
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
text = _element_to_markdown(child, base_url=base_url)
|
|
450
|
+
|
|
451
|
+
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
452
|
+
level = int(tag[1])
|
|
453
|
+
heading_text = child.text_content().strip()
|
|
454
|
+
heading_text = re.sub(r"\s*Section titled.*$", "", heading_text).strip()
|
|
455
|
+
parts.append(f"\n\n{'#' * level} {heading_text}\n\n")
|
|
456
|
+
elif tag == "p":
|
|
457
|
+
parts.append(f"\n\n{text}\n\n")
|
|
458
|
+
elif tag == "pre":
|
|
459
|
+
code = _pre_to_text(child)
|
|
460
|
+
# Detect language from data-language attr, class, or child classes
|
|
461
|
+
lang = child.get("data-language", "") or ""
|
|
462
|
+
if not lang:
|
|
463
|
+
classes = child.get("class", "") or " ".join(
|
|
464
|
+
c.get("class", "") for c in child.iterchildren()
|
|
465
|
+
)
|
|
466
|
+
for m in re.finditer(r"(?:language-|lang-)(\w+)", classes):
|
|
467
|
+
lang = m.group(1)
|
|
468
|
+
break
|
|
469
|
+
parts.append(f"\n\n```{lang}\n{code.strip()}\n```\n\n")
|
|
470
|
+
elif tag == "code":
|
|
471
|
+
# Inline code (not inside pre).
|
|
472
|
+
code_text = child.text_content().strip()
|
|
473
|
+
if code_text:
|
|
474
|
+
parts.append(f"`{code_text}`")
|
|
475
|
+
else:
|
|
476
|
+
parts.append(text)
|
|
477
|
+
elif tag in ("ul", "ol"):
|
|
478
|
+
items = _list_to_markdown(child, ordered=(tag == "ol"), base_url=base_url)
|
|
479
|
+
parts.append(f"\n\n{items}\n\n")
|
|
480
|
+
elif tag == "blockquote":
|
|
481
|
+
quoted = "\n".join(f"> {line}" for line in text.strip().split("\n"))
|
|
482
|
+
parts.append(f"\n\n{quoted}\n\n")
|
|
483
|
+
elif tag == "hr":
|
|
484
|
+
parts.append("\n\n---\n\n")
|
|
485
|
+
elif tag == "br":
|
|
486
|
+
parts.append("\n")
|
|
487
|
+
elif tag == "table":
|
|
488
|
+
table_md = _table_to_markdown(child)
|
|
489
|
+
if table_md:
|
|
490
|
+
parts.append(f"\n\n{table_md}\n\n")
|
|
491
|
+
elif tag == "a":
|
|
492
|
+
href = child.get("href", "")
|
|
493
|
+
link_text = child.text_content().strip()
|
|
494
|
+
if href and link_text and not href.startswith("#"):
|
|
495
|
+
href = _normalise_captured_url(href, base_url)
|
|
496
|
+
parts.append(f"[{link_text}]({href})")
|
|
497
|
+
elif link_text:
|
|
498
|
+
parts.append(link_text)
|
|
499
|
+
elif tag in ("strong", "b"):
|
|
500
|
+
t = child.text_content().strip()
|
|
501
|
+
if t:
|
|
502
|
+
parts.append(f"**{t}**")
|
|
503
|
+
elif tag in ("em", "i"):
|
|
504
|
+
t = child.text_content().strip()
|
|
505
|
+
if t:
|
|
506
|
+
parts.append(f"*{t}*")
|
|
507
|
+
elif tag == "img":
|
|
508
|
+
alt = child.get("alt", "")
|
|
509
|
+
src = child.get("src", "")
|
|
510
|
+
if src:
|
|
511
|
+
src = _normalise_captured_url(src, base_url)
|
|
512
|
+
parts.append(f"")
|
|
513
|
+
elif tag in ("div", "section", "span", "article", "main"):
|
|
514
|
+
parts.append(text)
|
|
515
|
+
else:
|
|
516
|
+
inner_text = (child.text or "").strip()
|
|
517
|
+
if inner_text:
|
|
518
|
+
parts.append(inner_text)
|
|
519
|
+
parts.append(text)
|
|
520
|
+
|
|
521
|
+
# Crucial: preserve the tail text (whitespace + text after this child).
|
|
522
|
+
if child.tail:
|
|
523
|
+
parts.append(child.tail)
|
|
524
|
+
|
|
525
|
+
return "".join(parts)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _normalise_captured_url(value: str, base_url: str) -> str:
|
|
529
|
+
candidate = str(value or "").strip()
|
|
530
|
+
if not candidate or candidate.startswith("#") or not base_url:
|
|
531
|
+
return candidate
|
|
532
|
+
parsed = urlparse(candidate)
|
|
533
|
+
if parsed.scheme and parsed.scheme.lower() not in {"http", "https"}:
|
|
534
|
+
return candidate
|
|
535
|
+
return urljoin(base_url, candidate)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _list_to_markdown(el, ordered: bool = False, base_url: str = "") -> str:
|
|
539
|
+
"""Convert a <ul> or <ol> element to markdown."""
|
|
540
|
+
lines: list[str] = []
|
|
541
|
+
idx = 0
|
|
542
|
+
for child in el:
|
|
543
|
+
if _tag_name(child) == "li":
|
|
544
|
+
idx += 1
|
|
545
|
+
prefix = f"{idx}. " if ordered else "- "
|
|
546
|
+
text = _element_to_markdown(child, base_url=base_url).strip()
|
|
547
|
+
# Handle nested lists
|
|
548
|
+
text = text.replace("\n", "\n ")
|
|
549
|
+
lines.append(f"{prefix}{text}")
|
|
550
|
+
return "\n".join(lines)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _table_to_markdown(el) -> str:
|
|
554
|
+
"""Best-effort conversion of a <table> to markdown table."""
|
|
555
|
+
rows = []
|
|
556
|
+
for tr in el.xpath(".//tr"):
|
|
557
|
+
cells = []
|
|
558
|
+
for cell in tr.xpath(".//td | .//th"):
|
|
559
|
+
cells.append(cell.text_content().strip().replace("|", "\\|"))
|
|
560
|
+
if cells:
|
|
561
|
+
rows.append("| " + " | ".join(cells) + " |")
|
|
562
|
+
if not rows:
|
|
563
|
+
return ""
|
|
564
|
+
# Add header separator after first row
|
|
565
|
+
header_cols = rows[0].count("|") - 1
|
|
566
|
+
separator = "| " + " | ".join(["---"] * header_cols) + " |"
|
|
567
|
+
rows.insert(1, separator)
|
|
568
|
+
return "\n".join(rows)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _clean_markdown(md: str) -> str:
|
|
572
|
+
"""Normalize whitespace, collapse excessive blank lines."""
|
|
573
|
+
# Decode HTML entities that survived
|
|
574
|
+
md = _html.unescape(md)
|
|
575
|
+
# Remove line-end indentation before collapsing blank lines. Pretty-printed
|
|
576
|
+
# HTML leaves spaces on otherwise empty lines, which would hide them from a
|
|
577
|
+
# newline-only regex and produce a very sparse reading view.
|
|
578
|
+
lines = [line.rstrip() for line in md.split("\n")]
|
|
579
|
+
md = "\n".join(lines)
|
|
580
|
+
md = re.sub(r"\n{3,}", "\n\n", md)
|
|
581
|
+
return md.strip()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def extract_page_language_and_alternates(
|
|
585
|
+
raw_html: str,
|
|
586
|
+
base_url: str = "",
|
|
587
|
+
) -> tuple[str, dict[str, str]]:
|
|
588
|
+
"""Extract page language attribute and alternate hreflang URLs.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
tuple of (language_code, {normalized_hreflang: absolute_url})
|
|
592
|
+
"""
|
|
593
|
+
from urllib.parse import urldefrag, urljoin
|
|
594
|
+
|
|
595
|
+
from lxml import html as lxml_html # nosec B410 - HTML parser, not XML
|
|
596
|
+
|
|
597
|
+
from deeptutor.services.web_source.bilingual import normalize_language
|
|
598
|
+
|
|
599
|
+
language = ""
|
|
600
|
+
alternates: dict[str, str] = {}
|
|
601
|
+
|
|
602
|
+
if not raw_html:
|
|
603
|
+
return language, alternates
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
tree = lxml_html.fromstring(raw_html)
|
|
607
|
+
except Exception:
|
|
608
|
+
return language, alternates
|
|
609
|
+
|
|
610
|
+
# 1. Page language from <html lang="..."> or <html xml:lang="...">
|
|
611
|
+
html_lang = tree.get("lang") or tree.get("xml:lang") or ""
|
|
612
|
+
if html_lang:
|
|
613
|
+
language = normalize_language(html_lang)
|
|
614
|
+
|
|
615
|
+
# 2. Fall back to <meta http-equiv="content-language" content="..."> or <meta name="language" content="...">
|
|
616
|
+
if not language:
|
|
617
|
+
meta_nodes = tree.xpath("//meta[@http-equiv or @name]")
|
|
618
|
+
for meta in meta_nodes:
|
|
619
|
+
equiv = (meta.get("http-equiv") or meta.get("name") or "").strip().lower()
|
|
620
|
+
if equiv in ("content-language", "language"):
|
|
621
|
+
content = (meta.get("content") or "").strip()
|
|
622
|
+
if content:
|
|
623
|
+
language = normalize_language(content)
|
|
624
|
+
break
|
|
625
|
+
|
|
626
|
+
# 3. Alternate links: <link rel="alternate" hreflang="..." href="...">
|
|
627
|
+
link_nodes = tree.xpath("//link[contains(@rel, 'alternate') and @hreflang and @href]")
|
|
628
|
+
for link in link_nodes:
|
|
629
|
+
hreflang = (link.get("hreflang") or "").strip()
|
|
630
|
+
href = (link.get("href") or "").strip()
|
|
631
|
+
if not hreflang or not href or hreflang.lower() in ("x-default", "default"):
|
|
632
|
+
continue
|
|
633
|
+
norm_lang = normalize_language(hreflang)
|
|
634
|
+
if not norm_lang:
|
|
635
|
+
continue
|
|
636
|
+
href, _ = urldefrag(href)
|
|
637
|
+
if base_url:
|
|
638
|
+
resolved = urljoin(base_url, href)
|
|
639
|
+
else:
|
|
640
|
+
resolved = href
|
|
641
|
+
alternates[norm_lang] = resolved
|
|
642
|
+
|
|
643
|
+
return language, alternates
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Markdown cleanup shared by web-source persistence and reading ingestion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_LEADING_SOURCE_COMMENT = re.compile(
|
|
8
|
+
r"^<!--\s*source:\s*https?://[^>\r\n]+?-->\s*$",
|
|
9
|
+
re.IGNORECASE,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def strip_leading_snapshot_provenance(markdown: str) -> str:
|
|
14
|
+
"""Remove only crawler provenance comments from a snapshot's preamble."""
|
|
15
|
+
lines = markdown.splitlines(keepends=True)
|
|
16
|
+
index = 0
|
|
17
|
+
while index < len(lines):
|
|
18
|
+
stripped = lines[index].strip()
|
|
19
|
+
if not stripped:
|
|
20
|
+
index += 1
|
|
21
|
+
continue
|
|
22
|
+
if not _LEADING_SOURCE_COMMENT.fullmatch(stripped):
|
|
23
|
+
break
|
|
24
|
+
index += 1
|
|
25
|
+
return "".join(lines[index:]).lstrip("\r\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["strip_leading_snapshot_provenance"]
|