brainiac-cli 0.16.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 (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ """ING-01/ING-02 — document ingestion pipeline (ADR-0003 Ruling 1).
2
+
3
+ Drop a file into ``<vault>/inbox/``; the HOST-only ``brain ingest`` verb (also
4
+ folded into every host ``brain sync``, per the s01 cadence amendment) extracts
5
+ it to Markdown, archives the untouched original immutably under
6
+ ``raw/originals/``, and commits the extracted source through the existing
7
+ audited ``write_note`` path. Unhandled/failed files quarantine to
8
+ ``inbox/_quarantine/<reason>/`` — never silently dropped.
9
+
10
+ Public surface: :func:`run_ingest` (orchestrator) and :func:`capability_report`
11
+ (which handlers are usable given installed deps) — both re-exported from
12
+ ``.pipeline`` and ``.handlers``.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .handlers import ExtractResult, Handler, capability_report
17
+ from .pipeline import inbox_dir, run_ingest
18
+
19
+ __all__ = ["ExtractResult", "Handler", "capability_report", "inbox_dir", "run_ingest"]
@@ -0,0 +1,43 @@
1
+ """Extension -> Handler registry (ING-01)."""
2
+ from __future__ import annotations
3
+
4
+ from .base import ExtractResult, Handler, density_gate, strip_control_chars
5
+ from .docx import DocxHandler
6
+ from .email import EmailHandler
7
+ from .html import HtmlHandler
8
+ from .image import ImageHandler
9
+ from .pdf import PdfHandler
10
+ from .pptx import PptxHandler
11
+ from .text import TextHandler
12
+ from .xlsx import XlsxHandler
13
+ from .zip import ZipHandler
14
+
15
+ ALL_HANDLERS: tuple[type[Handler], ...] = (
16
+ PdfHandler, DocxHandler, PptxHandler, XlsxHandler, TextHandler,
17
+ ImageHandler, EmailHandler, HtmlHandler, ZipHandler,
18
+ )
19
+
20
+ REGISTRY: dict[str, type[Handler]] = {
21
+ ext: handler for handler in ALL_HANDLERS for ext in handler.extensions
22
+ }
23
+
24
+
25
+ def handler_for(path) -> type[Handler] | None:
26
+ from pathlib import Path
27
+
28
+ return REGISTRY.get(Path(path).suffix.lower())
29
+
30
+
31
+ def capability_report() -> dict[str, dict]:
32
+ """Which extension handlers are available right now (ING-01 dep probe)."""
33
+ return {
34
+ ext: {"handler": handler.__name__, "dependency": handler.dependency_name,
35
+ "available": handler.available()}
36
+ for ext, handler in REGISTRY.items()
37
+ }
38
+
39
+
40
+ __all__ = [
41
+ "ExtractResult", "Handler", "density_gate", "strip_control_chars",
42
+ "ALL_HANDLERS", "REGISTRY", "handler_for", "capability_report",
43
+ ]
@@ -0,0 +1,95 @@
1
+ """Handler contract (ING-01/ING-02, ADR-0003 Ruling 1).
2
+
3
+ Every format handler implements one function contract: given a Path, return an
4
+ ``ExtractResult``. Handlers never touch the vault, the index, or the audit
5
+ chain — that is the orchestrator's job (``brain.ingest.run_ingest``). This
6
+ keeps a handler pure and trivially testable: bytes in, Markdown (or a
7
+ quarantine reason) out.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from abc import ABC, abstractmethod
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ # Below this many non-whitespace characters, a "successfully extracted"
18
+ # document is treated as empty/near-empty content — the OHRBench finding that
19
+ # upstream extraction failure (not the write path) is the dominant corpus-
20
+ # corruption vector. Quarantine, never sign a near-empty source.
21
+ MIN_CONTENT_CHARS = 40
22
+
23
+
24
+ @dataclass
25
+ class ExtractResult:
26
+ """Outcome of one handler's extraction attempt.
27
+
28
+ ``quarantine_reason`` is ``None`` on success. ``markdown``/``warnings``/
29
+ ``metadata`` are always populated (possibly empty) so callers never branch
30
+ on attribute presence.
31
+ """
32
+
33
+ markdown: str = ""
34
+ warnings: list[str] = field(default_factory=list)
35
+ quarantine_reason: str | None = None
36
+ metadata: dict[str, Any] = field(default_factory=dict)
37
+
38
+ @property
39
+ def ok(self) -> bool:
40
+ return self.quarantine_reason is None
41
+
42
+ @staticmethod
43
+ def quarantine(reason: str, *, warnings: list[str] | None = None) -> "ExtractResult":
44
+ return ExtractResult(quarantine_reason=reason, warnings=list(warnings or []))
45
+
46
+
47
+ def density_gate(markdown: str, *, min_chars: int = MIN_CONTENT_CHARS) -> str | None:
48
+ """Generic extraction-quality gate (HARDENED:grill): empty-text / low text
49
+ density detector shared by every handler. Strips table-unparsed fences and
50
+ page markers before counting so a document that is ENTIRELY scanned pages
51
+ or an unparsed table dump does not slip past on marker text alone.
52
+ Returns a quarantine reason, or ``None`` if the content passes."""
53
+ import re
54
+
55
+ stripped = re.sub(r"^#{1,3}\s.*$", "", markdown, flags=re.MULTILINE)
56
+ stripped = stripped.replace("```", "")
57
+ content_chars = len(stripped.strip())
58
+ if content_chars < min_chars:
59
+ return "empty_or_low_text_density"
60
+ return None
61
+
62
+
63
+ _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
64
+
65
+
66
+ def strip_control_chars(name: str) -> str:
67
+ """Strip control chars (incl. embedded newlines) from an untrusted name
68
+ (zip member, email attachment/header, HTML title, ...) before it flows
69
+ into generated Markdown body text or a report entry (S06 HARDENED — the
70
+ S05 lesson was frontmatter; a control char in body text can still forge a
71
+ fake heading/table row in the rendered note or a report line)."""
72
+ if not name:
73
+ return name
74
+ return _CONTROL_CHARS.sub("", name)
75
+
76
+
77
+ class Handler(ABC):
78
+ """One handler per file extension family."""
79
+
80
+ #: lower-cased extensions this handler claims, e.g. (".pdf",)
81
+ extensions: tuple[str, ...] = ()
82
+ #: human label for capability-probe reporting, e.g. "pypdf"
83
+ dependency_name: str = ""
84
+
85
+ @classmethod
86
+ @abstractmethod
87
+ def available(cls) -> bool:
88
+ """True iff this handler's extraction dependency import-succeeds."""
89
+
90
+ @classmethod
91
+ @abstractmethod
92
+ def extract(cls, path: Path) -> ExtractResult:
93
+ """Extract ``path`` to Markdown. MUST NOT raise on malformed input —
94
+ catch and return a quarantine ``ExtractResult`` instead (a crash here
95
+ would abort the whole drain, not just this one file)."""
@@ -0,0 +1,78 @@
1
+ """DOCX handler — python-docx. Paragraphs + tables (Markdown tables, headers
2
+ retained per HARDENED:grill — see .tables.rows_to_markdown)."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .base import ExtractResult, Handler, density_gate
8
+ from .tables import rows_to_markdown
9
+
10
+ try:
11
+ import docx # python-docx
12
+ _HAS_DOCX = True
13
+ except ImportError: # pragma: no cover
14
+ _HAS_DOCX = False
15
+
16
+ MAX_DOCX_BYTES = 100 * 1024 * 1024
17
+
18
+
19
+ class DocxHandler(Handler):
20
+ extensions = (".docx",)
21
+ dependency_name = "python-docx"
22
+
23
+ @classmethod
24
+ def available(cls) -> bool:
25
+ return _HAS_DOCX
26
+
27
+ @classmethod
28
+ def extract(cls, path: Path) -> ExtractResult:
29
+ if not _HAS_DOCX:
30
+ return ExtractResult.quarantine("missing_dependency:python-docx")
31
+ try:
32
+ size = path.stat().st_size
33
+ except OSError:
34
+ size = 0
35
+ if size > MAX_DOCX_BYTES:
36
+ return ExtractResult.quarantine("file_too_large")
37
+ try:
38
+ document = docx.Document(str(path))
39
+ except Exception as exc:
40
+ return ExtractResult.quarantine(
41
+ "docx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
42
+ )
43
+
44
+ parts: list[str] = []
45
+ try:
46
+ # Walk body children in document order so tables land inline with
47
+ # the surrounding prose rather than all at the end.
48
+ body = document.element.body
49
+ table_by_elem = {t._tbl: t for t in document.tables}
50
+ # C10: re-scanning document.paragraphs per body child was O(n^2).
51
+ # Build the element->paragraph lookup once, like table_by_elem.
52
+ para_by_elem = {p._p: p for p in document.paragraphs}
53
+ for child in body.iterchildren():
54
+ tag = child.tag.rsplit("}", 1)[-1]
55
+ if tag == "p":
56
+ p = para_by_elem.get(child)
57
+ if p is not None:
58
+ text = p.text.strip()
59
+ if text:
60
+ if p.style and p.style.name and p.style.name.startswith("Heading"):
61
+ parts.append(f"## {text}\n")
62
+ else:
63
+ parts.append(f"{text}\n")
64
+ elif tag == "tbl":
65
+ tbl = table_by_elem.get(child)
66
+ if tbl is not None:
67
+ rows = [[c.text for c in row.cells] for row in tbl.rows]
68
+ parts.append(rows_to_markdown(rows))
69
+ except Exception as exc:
70
+ return ExtractResult.quarantine(
71
+ "docx_extraction_error", warnings=[f"{type(exc).__name__}: {exc}"]
72
+ )
73
+
74
+ body_md = "\n".join(p for p in parts if p.strip())
75
+ reason = density_gate(body_md)
76
+ if reason:
77
+ return ExtractResult.quarantine(reason)
78
+ return ExtractResult(markdown=body_md, metadata={"tables": len(document.tables)})
@@ -0,0 +1,228 @@
1
+ """Email (.eml, RFC 5322) handler — stdlib only (``email`` + ``html.parser``,
2
+ mirrors the sha256-verified reference-vault reference, ADR-0003 Appendix B).
3
+ Produces headers + body + an attachment manifest; each attachment's bytes are
4
+ returned via ``metadata["nested"]`` so the orchestrator (pipeline.py
5
+ ``_process_nested``) re-enters the dispatcher for each one — bounded by
6
+ ``MAX_ATTACHMENTS``/``MAX_ATTACHMENT_TOTAL_BYTES`` here, and by depth +
7
+ a shared byte/count budget at the pipeline layer (defense in depth against a
8
+ crafted attachment-of-attachment chain)."""
9
+ from __future__ import annotations
10
+
11
+ import email
12
+ import email.policy
13
+ import email.utils
14
+ import html.parser
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from .base import ExtractResult, Handler, density_gate, strip_control_chars
20
+
21
+ MAX_EML_BYTES = 50 * 1024 * 1024
22
+ MAX_ATTACHMENTS = 50
23
+ MAX_ATTACHMENT_TOTAL_BYTES = 200 * 1024 * 1024 # matches pipeline.MAX_INGEST_BYTES
24
+
25
+ _EM_DASH = "—"
26
+
27
+
28
+ class _HtmlStripper(html.parser.HTMLParser):
29
+ """Minimal stdlib HTML->text fallback for a text/html-only body (no
30
+ BeautifulSoup dependency needed for this narrow use)."""
31
+
32
+ _BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6",
33
+ "blockquote", "pre", "hr"}
34
+ _SKIP = {"script", "style", "head", "meta", "link"}
35
+
36
+ def __init__(self) -> None:
37
+ super().__init__(convert_charrefs=True)
38
+ self._chunks: list[str] = []
39
+ self._skip = 0
40
+
41
+ def handle_starttag(self, tag: str, attrs: list) -> None:
42
+ if tag in self._SKIP:
43
+ self._skip += 1
44
+ elif tag in self._BLOCK:
45
+ self._chunks.append("\n")
46
+
47
+ def handle_endtag(self, tag: str) -> None:
48
+ if tag in self._SKIP and self._skip > 0:
49
+ self._skip -= 1
50
+ elif tag in self._BLOCK:
51
+ self._chunks.append("\n")
52
+
53
+ def handle_data(self, data: str) -> None:
54
+ if not self._skip:
55
+ self._chunks.append(data)
56
+
57
+ def get_text(self) -> str:
58
+ joined = "".join(self._chunks)
59
+ joined = re.sub(r"\n{3,}", "\n\n", joined)
60
+ return "\n".join(line.rstrip() for line in joined.split("\n")).strip()
61
+
62
+
63
+ def _strip_html(raw: str) -> str:
64
+ stripper = _HtmlStripper()
65
+ try:
66
+ stripper.feed(raw)
67
+ stripper.close()
68
+ except Exception:
69
+ return ""
70
+ return stripper.get_text()
71
+
72
+
73
+ def _decode_header(raw: object) -> str:
74
+ return str(raw).strip() if raw else ""
75
+
76
+
77
+ def _addr_list(raw: str) -> list[str]:
78
+ if not raw:
79
+ return []
80
+ out: list[str] = []
81
+ for name, addr in email.utils.getaddresses([raw]):
82
+ name, addr = name.strip(), addr.strip()
83
+ if name and addr:
84
+ out.append(f"{name} <{addr}>")
85
+ elif addr:
86
+ out.append(addr)
87
+ elif name:
88
+ out.append(name)
89
+ return out
90
+
91
+
92
+ def _sent_date_iso(raw: str) -> Optional[str]:
93
+ if not raw:
94
+ return None
95
+ try:
96
+ dt = email.utils.parsedate_to_datetime(raw)
97
+ return dt.isoformat() if dt is not None else None
98
+ except Exception:
99
+ return None
100
+
101
+
102
+ def _extract_body(msg: "email.message.Message") -> tuple[str, list[str]]:
103
+ warnings: list[str] = []
104
+ text_part = html_part = None
105
+ for part in msg.walk():
106
+ if part.is_multipart() or part.get_content_disposition() == "attachment":
107
+ continue
108
+ ctype = part.get_content_type()
109
+ if ctype == "text/plain" and text_part is None:
110
+ text_part = part
111
+ elif ctype == "text/html" and html_part is None:
112
+ html_part = part
113
+
114
+ if text_part is not None:
115
+ try:
116
+ return text_part.get_content().strip(), warnings
117
+ except Exception:
118
+ payload = text_part.get_payload(decode=True) or b""
119
+ charset = text_part.get_content_charset() or "utf-8"
120
+ return payload.decode(charset, errors="replace").strip(), warnings
121
+
122
+ if html_part is not None:
123
+ try:
124
+ raw_html = html_part.get_content()
125
+ except Exception:
126
+ payload = html_part.get_payload(decode=True) or b""
127
+ charset = html_part.get_content_charset() or "utf-8"
128
+ raw_html = payload.decode(charset, errors="replace")
129
+ warnings.append("html_only_fallback: no text/plain part, stripped HTML")
130
+ return _strip_html(raw_html), warnings
131
+
132
+ warnings.append("no_body_part: neither text/plain nor text/html present")
133
+ return "", warnings
134
+
135
+
136
+ class EmailHandler(Handler):
137
+ extensions = (".eml",)
138
+ dependency_name = "stdlib"
139
+
140
+ @classmethod
141
+ def available(cls) -> bool:
142
+ return True
143
+
144
+ @classmethod
145
+ def extract(cls, path: Path) -> ExtractResult:
146
+ try:
147
+ size = path.stat().st_size
148
+ except OSError:
149
+ size = 0
150
+ if size > MAX_EML_BYTES:
151
+ return ExtractResult.quarantine(
152
+ "file_too_large", warnings=[f"{size} bytes exceeds cap {MAX_EML_BYTES}"]
153
+ )
154
+ try:
155
+ raw = path.read_bytes()
156
+ except OSError as exc:
157
+ return ExtractResult.quarantine("eml_read_error", warnings=[f"{type(exc).__name__}: {exc}"])
158
+ try:
159
+ msg = email.message_from_bytes(raw, policy=email.policy.default)
160
+ except Exception as exc:
161
+ return ExtractResult.quarantine("eml_parse_error", warnings=[f"{type(exc).__name__}: {exc}"])
162
+
163
+ subject = strip_control_chars(_decode_header(msg.get("Subject")))
164
+ from_addrs = _addr_list(_decode_header(msg.get("From")))
165
+ to_addrs = _addr_list(_decode_header(msg.get("To")))
166
+ cc_addrs = _addr_list(_decode_header(msg.get("Cc")))
167
+ sent_raw = _decode_header(msg.get("Date"))
168
+ sent_iso = _sent_date_iso(sent_raw)
169
+ body_text, warnings = _extract_body(msg)
170
+
171
+ try:
172
+ attachments = list(msg.iter_attachments())
173
+ except Exception:
174
+ attachments = []
175
+ if len(attachments) > MAX_ATTACHMENTS:
176
+ warnings.append(f"attachments_truncated: {len(attachments)} found, cap {MAX_ATTACHMENTS}")
177
+ attachments = attachments[:MAX_ATTACHMENTS]
178
+
179
+ nested: list[dict] = []
180
+ attach_meta: list[tuple[str, str, int]] = []
181
+ total_bytes = 0
182
+ for idx, part in enumerate(attachments, start=1):
183
+ name = strip_control_chars(part.get_filename() or f"attachment_{idx}.bin")
184
+ try:
185
+ data = part.get_payload(decode=True) or b""
186
+ except Exception as exc:
187
+ warnings.append(f"attachment_decode_failed:{name}:{type(exc).__name__}")
188
+ continue
189
+ if total_bytes + len(data) > MAX_ATTACHMENT_TOTAL_BYTES:
190
+ warnings.append(f"attachment_byte_cap_reached: stopped before {name}")
191
+ break
192
+ total_bytes += len(data)
193
+ attach_meta.append((name, part.get_content_type(), len(data)))
194
+ nested.append({"name": name, "data": data})
195
+
196
+ from_disp = "; ".join(from_addrs) if from_addrs else _EM_DASH
197
+ to_disp = "; ".join(to_addrs) if to_addrs else _EM_DASH
198
+
199
+ lines = [
200
+ "## Email metadata", "",
201
+ f"- **Subject:** {subject or '(no subject)'}",
202
+ f"- **From:** {from_disp}",
203
+ f"- **To:** {to_disp}",
204
+ ]
205
+ if cc_addrs:
206
+ lines.append(f"- **Cc:** {'; '.join(cc_addrs)}")
207
+ if sent_iso:
208
+ lines.append(f"- **Sent:** {sent_iso} (raw: {sent_raw})")
209
+ elif sent_raw:
210
+ lines.append(f"- **Sent:** {sent_raw}")
211
+ if attach_meta:
212
+ lines.append(f"- **Attachments:** {len(attach_meta)}")
213
+ lines += ["", "## Body", "", body_text or "*(empty body)*", ""]
214
+ if attach_meta:
215
+ lines.append("## Attachments")
216
+ lines.append("")
217
+ for name, ctype, nbytes in attach_meta:
218
+ lines.append(f"- `{name}` — {ctype} ({nbytes / 1024:.1f} KB)")
219
+ lines.append("")
220
+ body_md = "\n".join(lines)
221
+
222
+ reason = density_gate(body_md)
223
+ if reason:
224
+ return ExtractResult.quarantine(reason, warnings=warnings)
225
+ return ExtractResult(
226
+ markdown=body_md, warnings=warnings,
227
+ metadata={"nested": nested, "attachment_count": len(attach_meta), "subject": subject},
228
+ )
@@ -0,0 +1,142 @@
1
+ """HTML handler — stdlib ``html.parser`` readable-text conversion (no new
2
+ required dependency); ``lxml`` is used as an optional faster/more-robust path
3
+ when already installed, mirroring the sha256-verified reference-vault reference
4
+ (ADR-0003 Appendix B) which proves the stdlib fallback alone is production-
5
+ adequate."""
6
+ from __future__ import annotations
7
+
8
+ import html as _html_stdlib
9
+ import html.parser
10
+ import re
11
+ from pathlib import Path
12
+
13
+ from .base import ExtractResult, Handler, density_gate
14
+
15
+ MAX_HTML_BYTES = 50 * 1024 * 1024
16
+
17
+ _SKIP_TAGS = frozenset({"script", "style", "noscript", "svg", "canvas", "iframe", "object", "embed"})
18
+ _BLOCK_TAGS = frozenset({
19
+ "div", "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
20
+ "li", "dt", "dd", "tr", "td", "th", "section", "article", "header",
21
+ "footer", "nav", "main", "aside", "pre", "blockquote", "table",
22
+ "thead", "tbody", "tfoot", "ul", "ol", "dl", "figure", "figcaption",
23
+ })
24
+
25
+
26
+ class _TextExtractor(html.parser.HTMLParser):
27
+ """Minimal HTML -> plain-text via stdlib. Void elements (br, hr, meta,
28
+ link, ...) fire ``handle_starttag`` but never ``handle_endtag`` — they are
29
+ deliberately absent from ``_SKIP_TAGS`` so ``_skip`` can never be left
30
+ incremented forever by one."""
31
+
32
+ def __init__(self) -> None:
33
+ super().__init__(convert_charrefs=True)
34
+ self._chunks: list[str] = []
35
+ self._skip = 0
36
+
37
+ def handle_starttag(self, tag: str, attrs: list) -> None:
38
+ tag = tag.lower()
39
+ if tag in _SKIP_TAGS:
40
+ self._skip += 1
41
+ if tag in _BLOCK_TAGS:
42
+ self._chunks.append("\n")
43
+
44
+ def handle_endtag(self, tag: str) -> None:
45
+ tag = tag.lower()
46
+ if tag in _SKIP_TAGS:
47
+ self._skip = max(0, self._skip - 1)
48
+ if tag in _BLOCK_TAGS:
49
+ self._chunks.append("\n")
50
+
51
+ def handle_data(self, data: str) -> None:
52
+ if not self._skip:
53
+ self._chunks.append(data)
54
+
55
+ def get_text(self) -> str:
56
+ raw = "".join(self._chunks)
57
+ lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in raw.splitlines()]
58
+ text = "\n".join(lines)
59
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
60
+
61
+
62
+ def _extract_title(raw_html: str) -> str | None:
63
+ m = re.search(r"<title[^>]*>(.*?)</title>", raw_html, re.IGNORECASE | re.DOTALL)
64
+ if not m:
65
+ return None
66
+ title = _html_stdlib.unescape(m.group(1))
67
+ title = re.sub(r"\s+", " ", title).strip()
68
+ return title or None
69
+
70
+
71
+ def _extract_text(raw_html: str) -> tuple[str, list[str]]:
72
+ warnings: list[str] = []
73
+ try:
74
+ from lxml.html import fromstring as _fromstring
75
+
76
+ doc = _fromstring(raw_html)
77
+ for bad in doc.xpath("//script|//style|//noscript"):
78
+ bad.drop_tree()
79
+ text = doc.text_content()
80
+ text = re.sub(r"[ \t]+", " ", text)
81
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
82
+ if text:
83
+ return text, warnings
84
+ # fall through to stdlib if lxml produced nothing
85
+ except ImportError:
86
+ pass # lxml not installed — stdlib fallback below
87
+ except Exception as exc:
88
+ warnings.append(f"lxml_parse_warning: {type(exc).__name__}: {exc}")
89
+
90
+ extractor = _TextExtractor()
91
+ try:
92
+ extractor.feed(raw_html)
93
+ return extractor.get_text(), warnings
94
+ except Exception as exc:
95
+ warnings.append(f"html_parse_warning: {type(exc).__name__}: {exc}")
96
+ text = re.sub(r"<[^>]+>", " ", raw_html)
97
+ return re.sub(r"\s+", " ", text).strip(), warnings
98
+
99
+
100
+ class HtmlHandler(Handler):
101
+ extensions = (".html", ".htm")
102
+ dependency_name = "stdlib"
103
+
104
+ @classmethod
105
+ def available(cls) -> bool:
106
+ return True
107
+
108
+ @classmethod
109
+ def extract(cls, path: Path) -> ExtractResult:
110
+ try:
111
+ size = path.stat().st_size
112
+ except OSError:
113
+ size = 0
114
+ if size > MAX_HTML_BYTES:
115
+ return ExtractResult.quarantine(
116
+ "file_too_large", warnings=[f"{size} bytes exceeds cap {MAX_HTML_BYTES}"]
117
+ )
118
+ try:
119
+ raw = path.read_bytes()
120
+ except OSError as exc:
121
+ return ExtractResult.quarantine("html_read_error", warnings=[f"{type(exc).__name__}: {exc}"])
122
+
123
+ text_raw = None
124
+ for enc in ("utf-8", "latin-1"):
125
+ try:
126
+ text_raw = raw.decode(enc)
127
+ break
128
+ except UnicodeDecodeError:
129
+ continue
130
+ if text_raw is None:
131
+ return ExtractResult.quarantine("html_decode_error")
132
+
133
+ title = _extract_title(text_raw)
134
+ body, warnings = _extract_text(text_raw)
135
+ if not body:
136
+ return ExtractResult.quarantine("empty_or_low_text_density", warnings=warnings)
137
+
138
+ markdown = f"# {title}\n\n{body}\n" if title else f"{body}\n"
139
+ reason = density_gate(markdown)
140
+ if reason:
141
+ return ExtractResult.quarantine(reason, warnings=warnings)
142
+ return ExtractResult(markdown=markdown, warnings=warnings, metadata={"title": title})