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.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. 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"![{alt}]({src})")
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"]