htmlsift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ """
2
+ Readable-markdown renderer with block-level DOM provenance.
3
+
4
+ One walk of a parsed HTML tree emits markdown lines and, per line, the set of
5
+ source DOM elements that produced it (line_src). That provenance lets a caller
6
+ label or serialize each rendered line by its DOM origin with no marker injection,
7
+ no re-render, and no alignment step. The renderer is board-agnostic: it does not
8
+ sanitize and knows nothing of any benchmark's annotations.
9
+
10
+ Layout tables (page structure built from <table>) are flowed as ordinary blocks so
11
+ their regions stay independently labelable; data tables render as markdown tables.
12
+
13
+ parse(html) -> lxml tree
14
+ render_tree(tree) -> (lines, line_src) # labeling / serialization
15
+ render(html) -> markdown text # inference
16
+ """
17
+
18
+ import re
19
+
20
+ from lxml import html as lxml_html
21
+
22
+ COLLAPSE_RE = re.compile(r"\s+")
23
+ SKIP_TAGS = {"head", "script", "style", "title", "meta", "link", "base"}
24
+ SIMPLE_BLOCK_TAGS = {"p", "div", "section", "article", "header", "footer", "main",
25
+ "aside", "figure", "figcaption", "fieldset"}
26
+ HEADING_TAGS = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
27
+ WRAP_TAGS = {"em": ("_", "_"), "i": ("_", "_"), "strong": ("**", "**"),
28
+ "b": ("**", "**"), "code": ("`", "`"), "del": ("~~", "~~"),
29
+ "s": ("~~", "~~"), "strike": ("~~", "~~")}
30
+ LIST_TAGS = {"ul", "ol"}
31
+ ROW_GROUP_TAGS = {"thead", "tbody", "tfoot"}
32
+
33
+ # A cell holding any of these is page-structure, not tabular data.
34
+ BLOCKISH_TAGS = (SIMPLE_BLOCK_TAGS | set(HEADING_TAGS) | LIST_TAGS |
35
+ {"table", "blockquote"})
36
+
37
+
38
+ def parse(html_str):
39
+ """HTML string -> lxml tree. Bytes-encode when the string carries an encoding
40
+ declaration, which lxml rejects on a str."""
41
+ parser = lxml_html.HTMLParser(collect_ids=False, encoding="utf-8",
42
+ remove_blank_text=True, remove_comments=True,
43
+ remove_pis=True)
44
+ if isinstance(html_str, str) and (
45
+ "<?xml" in html_str or "<meta charset" in html_str or "encoding=" in html_str):
46
+ html_str = html_str.encode("utf-8")
47
+ return lxml_html.fromstring(html_str, parser=parser)
48
+
49
+
50
+ def _direct_cells(el):
51
+ """Cells of this table's own rows (not nested tables')."""
52
+ def rows(node):
53
+ for ch in node:
54
+ t = ch.tag if isinstance(ch.tag, str) else ""
55
+ if t in ROW_GROUP_TAGS:
56
+ yield from rows(ch)
57
+ elif t == "tr":
58
+ yield ch
59
+ return [c for tr in rows(el) for c in tr
60
+ if isinstance(c.tag, str) and c.tag.lower() in ("td", "th")]
61
+
62
+
63
+ def _is_layout_table(el):
64
+ """A <table> used for page layout, not data: no <th> in its OWN cells, and at
65
+ least one own cell holds block-level content (a nested <table>, <div>, …).
66
+ Checks own cells only — a nested data table with <th> must not mask an outer
67
+ layout table."""
68
+ cells = _direct_cells(el)
69
+ if any(c.tag.lower() == "th" for c in cells):
70
+ return False
71
+ for c in cells:
72
+ if any(isinstance(d.tag, str) and d.tag.lower() in BLOCKISH_TAGS
73
+ for d in c.iterdescendants()):
74
+ return True
75
+ return False
76
+
77
+
78
+ def _cell_text(el):
79
+ """Flatten a table cell (incl. any nested table) to one readable inline string,
80
+ dropping script/style subtrees; source = the cell's elements."""
81
+ parts, src = [], set()
82
+
83
+ def rec(node):
84
+ if isinstance(node.tag, str):
85
+ if node.tag.lower() in SKIP_TAGS:
86
+ return
87
+ src.add(node)
88
+ if node.text:
89
+ parts.append(node.text)
90
+ for c in node:
91
+ rec(c)
92
+ if c.tail:
93
+ parts.append(c.tail)
94
+
95
+ rec(el)
96
+ txt = COLLAPSE_RE.sub(" ", "".join(parts)).strip().replace("|", "\\|")
97
+ return txt, src
98
+
99
+
100
+ class _Renderer:
101
+ """Accumulates output lines and, per line, the set of source elements."""
102
+
103
+ def __init__(self, render_hidden=False, links=False):
104
+ self.render_hidden = render_hidden
105
+ self.links = links
106
+ self.lines = []
107
+ self.line_src = []
108
+ self.buf = []
109
+ self.buf_src = []
110
+ self.linepfx = ""
111
+ self.pending = None
112
+ self.swallow = False
113
+ self.keep_lead = False
114
+ self.quote = 0
115
+ self.list_stack = []
116
+
117
+ def _qpfx(self):
118
+ return "> " * self.quote
119
+
120
+ def _append(self, line, src):
121
+ self.swallow = False
122
+ if self.pending is not None:
123
+ self.lines.append(self.pending); self.line_src.append(set())
124
+ self.pending = None
125
+ self.lines.append(line); self.line_src.append(src)
126
+
127
+ def flush(self):
128
+ if not self.buf:
129
+ return
130
+ src = {s for s in self.buf_src if s is not None}
131
+ self._append((self._qpfx() + self.linepfx + "".join(self.buf)).rstrip(), src)
132
+ self.buf = []; self.buf_src = []; self.linepfx = ""
133
+
134
+ def blank(self):
135
+ self.flush()
136
+ if not self.lines:
137
+ return
138
+ self.pending = self._qpfx().rstrip() if self.quote else ""
139
+
140
+ def emit(self, line, src):
141
+ """Emit a standalone line with an explicit source set."""
142
+ self.flush()
143
+ self._append(self._qpfx() + line, src if src else set())
144
+
145
+ def text(self, s, src):
146
+ if not s:
147
+ return
148
+ s = COLLAPSE_RE.sub(" ", s)
149
+ if self.swallow:
150
+ s = s.lstrip(" "); self.swallow = False
151
+ if not s:
152
+ return
153
+ if not self.buf and not self.keep_lead:
154
+ s = s.lstrip()
155
+ if not s:
156
+ return
157
+ self.buf.append(s); self.buf_src.append(src)
158
+
159
+ def raw(self, s, src):
160
+ self.buf.append(s); self.buf_src.append(src)
161
+
162
+ def children(self, el):
163
+ self.text(el.text, el)
164
+ for child in el:
165
+ self.walk(child)
166
+
167
+ def inner_keeping_lead(self, el):
168
+ prev = self.keep_lead; self.keep_lead = True
169
+ self.children(el); self.keep_lead = prev
170
+
171
+ def walk(self, el):
172
+ tag = el.tag
173
+ if not isinstance(tag, str):
174
+ return
175
+ tag = tag.lower()
176
+ style = el.get("style") or ""
177
+ hidden = ("display: none" in style or "display:none" in style) and not self.render_hidden
178
+ if tag in SKIP_TAGS or hidden:
179
+ pass # skip subtree; tail still renders below
180
+ elif tag == "br":
181
+ self.flush()
182
+ elif tag == "hr":
183
+ self.blank(); self.emit("* * *", {el}); self.blank()
184
+ elif tag in HEADING_TAGS:
185
+ self.blank()
186
+ self.linepfx = "#" * HEADING_TAGS[tag] + " "
187
+ self.inner_keeping_lead(el)
188
+ self.blank()
189
+ elif tag in LIST_TAGS:
190
+ self.list(el, tag)
191
+ elif tag == "li":
192
+ self.item(el)
193
+ elif tag == "blockquote":
194
+ self.blank(); self.quote += 1; self.children(el)
195
+ self.flush(); self.quote -= 1; self.blank()
196
+ elif tag == "pre":
197
+ self.pre(el)
198
+ elif tag == "table":
199
+ self.table(el)
200
+ elif tag == "dt":
201
+ self.flush(); self.children(el); self.flush()
202
+ elif tag == "option": # <select> options; own block each, else they glue
203
+ self.flush(); self.children(el); self.flush()
204
+ elif tag == "dd":
205
+ self.flush(); self.linepfx = " "
206
+ self.inner_keeping_lead(el); self.flush()
207
+ elif tag in SIMPLE_BLOCK_TAGS:
208
+ self.blank(); self.children(el); self.blank()
209
+ elif tag in WRAP_TAGS:
210
+ open_s, close_s = WRAP_TAGS[tag]
211
+ inner = _sub_inline(el, self.render_hidden, self.links)
212
+ if inner:
213
+ self.raw(open_s + inner + close_s, el)
214
+ else:
215
+ self.swallow = True
216
+ elif tag == "a" and self.links and el.get("href"):
217
+ inner = _sub_inline(el, self.render_hidden, self.links)
218
+ if inner:
219
+ self.raw(f"[{inner}]({el.get('href')})", el)
220
+ elif tag == "img" and self.links and el.get("src"):
221
+ self.raw(f"![{el.get('alt', '')}]({el.get('src')})", el)
222
+ else:
223
+ self.children(el) # a, span, abbr, sectioning-inline, ...
224
+ # el.tail is stored on el and survives pruning iff el is kept, so it is
225
+ # el's provenance (not the parent's).
226
+ self.text(el.tail, el)
227
+
228
+ def list(self, el, kind):
229
+ self.flush()
230
+ try:
231
+ start = int(el.get("start", "1"))
232
+ except ValueError:
233
+ start = 1
234
+ self.list_stack.append([kind, start])
235
+ self.text(el.text, el)
236
+ for child in el:
237
+ ctag = child.tag if isinstance(child.tag, str) else ""
238
+ if ctag.lower() == "li":
239
+ self.item(child)
240
+ else:
241
+ self.walk(child)
242
+ self.list_stack.pop()
243
+ self.flush(); self.blank()
244
+
245
+ def item(self, el):
246
+ self.flush()
247
+ depth = max(len(self.list_stack), 1)
248
+ indent = " " * (depth - 1)
249
+ if self.list_stack and self.list_stack[-1][0] == "ol":
250
+ n = self.list_stack[-1][1]; self.list_stack[-1][1] = n + 1
251
+ self.linepfx += f"{indent}{n}. "
252
+ else:
253
+ self.linepfx += f"{indent}- "
254
+ self.children(el)
255
+ self.flush()
256
+
257
+ def pre(self, el):
258
+ self.blank()
259
+ self._append("```", {el})
260
+ for line in el.text_content().split("\n"):
261
+ self._append(line, {el})
262
+ self._append("```", {el})
263
+ self.blank()
264
+
265
+ def table(self, el):
266
+ def rows(node):
267
+ for child in node:
268
+ ctag = child.tag if isinstance(child.tag, str) else ""
269
+ if ctag in ROW_GROUP_TAGS:
270
+ yield from rows(child)
271
+ elif ctag == "tr":
272
+ yield child
273
+ if _is_layout_table(el):
274
+ self.blank()
275
+ for tr in rows(el):
276
+ for c in tr:
277
+ if isinstance(c.tag, str) and c.tag.lower() in ("td", "th"):
278
+ self.blank(); self.children(c); self.blank()
279
+ self.blank()
280
+ return
281
+ self.blank()
282
+ first = True
283
+ for tr in rows(el):
284
+ cells = [c for c in tr if isinstance(c.tag, str)
285
+ and c.tag.lower() in ("td", "th")]
286
+ if not cells:
287
+ continue
288
+ texts, src = [], {tr}
289
+ for c in cells:
290
+ t, s = _cell_text(c)
291
+ texts.append(t); src |= s
292
+ if not any(texts): # skip fully-empty rows
293
+ continue
294
+ self.emit("| " + " | ".join(texts) + " |", src)
295
+ if first:
296
+ self.emit("| " + " | ".join(["---"] * len(cells)) + " |", src)
297
+ first = False
298
+ self.blank()
299
+
300
+
301
+ def _sub_inline(el, render_hidden=False, links=False):
302
+ sub = _Renderer(render_hidden, links); sub.children(el); sub.flush()
303
+ return " ".join(ln.strip() for ln in sub.lines if ln.strip())
304
+
305
+
306
+ def render_tree(root, render_hidden=False, links=False):
307
+ """Walk a parsed tree -> (lines, line_src). line_src[i] is the set of source
308
+ DOM elements that produced line i (empty for structural separators).
309
+
310
+ render_hidden keeps display:none subtrees instead of skipping them. Default
311
+ off — a reference built by a pruner that also skips display:none stays
312
+ symmetric with the render. A reference captured from a hydrated DOM sets it
313
+ on, so hydration-hidden main content is rendered.
314
+
315
+ links=True renders <a>/<img> as markdown ([text](href), ![alt](src)) -- an
316
+ output-only mode. The model-input render keeps links=False (link-flattened) so
317
+ training still matches; only serialization sets links=True."""
318
+ r = _Renderer(render_hidden, links); r.walk(root); r.flush()
319
+ return r.lines, r.line_src
320
+
321
+
322
+ def render(html_str, render_hidden=False, links=False):
323
+ """HTML string -> readable markdown text (inference; provenance discarded).
324
+ Does not sanitize — a caller that needs it sanitizes the tree first."""
325
+ if not html_str or not html_str.strip():
326
+ return ""
327
+ lines, _ = render_tree(parse(html_str), render_hidden, links)
328
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,38 @@
1
+ """Adapter: a `tokenizers.Tokenizer` presented with the transformers fast-tokenizer
2
+ call shape that `prep_page` expects, so the default install tokenizes with the small
3
+ `tokenizers` library and needs no `transformers` or `torch`.
4
+
5
+ `prep_page` calls the tokenizer as
6
+ `tok(text, add_special_tokens=True, return_offsets_mapping=True,
7
+ return_special_tokens_mask=True, ...)` and reads `input_ids`, `offset_mapping` and
8
+ `special_tokens_mask` off the result. The `tokenizers` library carries the same
9
+ information on its `Encoding` object under different names, so this wraps the two up.
10
+ """
11
+ from tokenizers import Tokenizer
12
+
13
+
14
+ class FastTokenizer:
15
+ """A `tokenizers.Tokenizer` callable like a transformers fast tokenizer:
16
+ `tok(text)` -> `{"input_ids", "offset_mapping", "special_tokens_mask"}`.
17
+
18
+ Truncation and padding are disabled so a long page reaches the encoder windowing
19
+ intact -- `prep_page` windows the sequence, the tokenizer must never cut it. The
20
+ special-token post-processor and normalizer baked into `tokenizer.json` are
21
+ applied by `encode`, so the ids, offsets and mask match what the model was
22
+ trained on."""
23
+
24
+ def __init__(self, tk):
25
+ tk.no_truncation()
26
+ tk.no_padding()
27
+ self._tk = tk
28
+
29
+ @classmethod
30
+ def from_file(cls, path):
31
+ """Load `tokenizer.json` at `path` -- the single self-contained tokenizer file."""
32
+ return cls(Tokenizer.from_file(str(path)))
33
+
34
+ def __call__(self, text, add_special_tokens=True, **_ignored):
35
+ enc = self._tk.encode(text, add_special_tokens=add_special_tokens)
36
+ return {"input_ids": enc.ids,
37
+ "offset_mapping": enc.offsets,
38
+ "special_tokens_mask": enc.special_tokens_mask}
@@ -0,0 +1,165 @@
1
+ """Torch backend for the base model: build the truncated granite encoder from a baked
2
+ config (no backbone download), pool block token states, run the BiGRU head. The
3
+ inference forward (pool / stitch / encode_window / pool_page / infer_page) is byte-
4
+ equivalent to the research original; construction uses AutoModel.from_config so the
5
+ package never pulls granite.
6
+
7
+ Entry point: load_base(...) -> infer_fn, the torch mirror of onnx_infer.load_mini.
8
+ """
9
+ import warnings
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn as nn
15
+ from transformers import AutoConfig, AutoModel
16
+
17
+ from .features import feature_dim
18
+ from .prep_page import window_starts
19
+
20
+
21
+ def build_encoder(config, device="cpu", attn="sdpa"):
22
+ """Build the base encoder from a baked (already layer-truncated) config and give it
23
+ the fresh final LayerNorm the trained weights expect. from_config -- not
24
+ from_pretrained -- so no granite backbone is downloaded; the weights are loaded by
25
+ load_base."""
26
+ model = AutoModel.from_config(config, attn_implementation=attn)
27
+ model.final_norm = nn.LayerNorm(model.config.hidden_size, eps=1e-5)
28
+ return model.to(device)
29
+
30
+
31
+ class BiGRUHead(nn.Module):
32
+ """Per-block head: pooled block vectors [1, n, d_in] -> per-block logits [1, n]."""
33
+
34
+ def __init__(self, d_in, hidden=256, dropout=0.2):
35
+ super().__init__()
36
+ self.gru = nn.GRU(d_in, hidden, num_layers=1, bidirectional=True, batch_first=True)
37
+ self.drop = nn.Dropout(dropout)
38
+ self.out = nn.Linear(2 * hidden, 1)
39
+
40
+ def forward(self, x, mask=None):
41
+ h, _ = self.gru(x)
42
+ return self.out(self.drop(h)).squeeze(-1)
43
+
44
+
45
+ def build_head(kind, d_in, hidden=256, dropout=0.2):
46
+ """The base model ships the BiGRU head only."""
47
+ if kind == "bigru":
48
+ return BiGRUHead(d_in, hidden=hidden, dropout=dropout)
49
+ raise ValueError(f"unknown head {kind!r}; the package ships 'bigru' only")
50
+
51
+
52
+ def pool(hidden, members, b_lo, b_hi, tok_off):
53
+ """Mean-pool encoder states hidden [w, H] into per-block vectors [b_hi-b_lo, H].
54
+ members hold page-level token indices; tok_off is the page index of hidden[0], so
55
+ m - tok_off is the window-local row. A block with no tokens pools to a zero vector."""
56
+ dev = hidden.device
57
+ blocks = members[b_lo:b_hi]
58
+ lengths = np.fromiter((len(m) for m in blocks), np.int64, len(blocks))
59
+ counts = torch.tensor(np.maximum(lengths, 1), device=dev,
60
+ dtype=hidden.dtype).unsqueeze(1)
61
+ pooled = hidden.new_zeros((b_hi - b_lo, hidden.shape[1]))
62
+ if lengths.sum():
63
+ mem_idx = np.concatenate([np.asarray(m, np.int64) for m in blocks if m]) - tok_off
64
+ blk_idx = np.repeat(np.arange(len(blocks), dtype=np.int64), lengths)
65
+ pooled = pooled.index_add(0, torch.from_numpy(blk_idx).to(dev),
66
+ hidden[torch.from_numpy(mem_idx).to(dev)])
67
+ return pooled / counts
68
+
69
+
70
+ def cat_feats(x, feats, b_lo, b_hi):
71
+ """Concatenate per-block feature columns onto pooled vectors x [n, H] -> [n, H+K].
72
+ No-op when feats is None (base is text-only)."""
73
+ if feats is None:
74
+ return x
75
+ f = torch.as_tensor(feats[b_lo:b_hi], dtype=x.dtype, device=x.device)
76
+ return torch.cat([x, f], dim=1)
77
+
78
+
79
+ def stitch_bounds(starts, window, k):
80
+ """Window-local [lo, hi) slice window k owns under most-interior ownership: each
81
+ token goes to the window whose centre is nearest. Used by the encoder stitch, so
82
+ the ownership rule lives in one place."""
83
+ s = starts[k]
84
+ lo = 0 if k == 0 else (starts[k - 1] + window + s) // 2 - s
85
+ hi = window if k == len(starts) - 1 else (s + window + starts[k + 1]) // 2 - s
86
+ return lo, hi
87
+
88
+
89
+ def encode_window(encoder, ids_t, autocast):
90
+ """Encode a 1-D token window -> hidden states [w, H] (fp32). bf16 autocast on the
91
+ forward when enabled (CUDA); a no-op on CPU."""
92
+ with torch.autocast("cuda", dtype=torch.bfloat16, enabled=autocast):
93
+ h = encoder(input_ids=ids_t[None],
94
+ attention_mask=torch.ones_like(ids_t)[None]).last_hidden_state[0]
95
+ return h.float()
96
+
97
+
98
+ @torch.no_grad()
99
+ def pool_page(encoder, page, dev, autocast):
100
+ """Full-page pooled block vectors [n_blocks, H]. A page within its window encodes in
101
+ one pass; a longer page encodes in overlapping windows stitched by most-interior
102
+ ownership, then the whole page is pooled. window/stride come from the page."""
103
+ window, stride = page["window"], page.get("stride")
104
+ ids = torch.from_numpy(page["ids"]).to(dev)
105
+ n = len(ids)
106
+ if n <= window:
107
+ hidden = encode_window(encoder, ids, autocast)
108
+ else:
109
+ hidden = torch.full((n, encoder.config.hidden_size), float("nan"), device=dev)
110
+ starts = window_starts(n, window, stride)
111
+ for k, s in enumerate(starts):
112
+ h = encode_window(encoder, ids[s:s + window], autocast)
113
+ lo, hi = stitch_bounds(starts, window, k)
114
+ hidden[s + lo:s + hi] = h[lo:hi]
115
+ assert not torch.isnan(hidden).any(), f"stitch gap {page['tid']}"
116
+ return pool(hidden, page["members"], 0, len(page["members"]), 0)
117
+
118
+
119
+ @torch.no_grad()
120
+ def infer_page(encoder, head, page, dev, autocast):
121
+ """Full-page inference -> per-block probs (np.float32): pool the page, concat any
122
+ features, run the head."""
123
+ x = pool_page(encoder, page, dev, autocast)
124
+ x = cat_feats(x, page.get("feats"), 0, len(page["members"]))
125
+ probs = torch.sigmoid(head(x[None])[0])
126
+ return probs.float().cpu().numpy()
127
+
128
+
129
+ def load_base(config_path, weights_path, hidden, feats=None, device=None,
130
+ band=True, threads=None):
131
+ """Load the base bundle into an infer_fn (page -> per-block probs) -- the torch
132
+ mirror of onnx_infer.load_mini. Build the architecture from the baked config, load
133
+ the trained encoder + head weights, optionally enable band attention, and close over
134
+ the forward. `hidden` is the BiGRU head hidden size; `feats` the feature group (base
135
+ is text-only -> None). No granite download.
136
+
137
+ device=None auto-selects cuda when a GPU is present, else cpu -- and warns on the
138
+ cpu fallback, so a silent CPU run can't pass for a GPU one. An explicit "cuda" with
139
+ no GPU is a clear error rather than a cryptic failure deeper in torch."""
140
+ if device is None:
141
+ device = "cuda" if torch.cuda.is_available() else "cpu"
142
+ if device == "cpu":
143
+ warnings.warn(
144
+ "htmlsift: base defaulting to CPU (no CUDA device found); "
145
+ "pass device='cpu' to silence, or device='cuda' to require GPU",
146
+ stacklevel=2)
147
+ dev = torch.device(device)
148
+ if dev.type == "cuda" and not torch.cuda.is_available():
149
+ raise ValueError(f"device={device!r} requested but no CUDA device is available")
150
+ if threads and dev.type == "cpu":
151
+ torch.set_num_threads(threads)
152
+ config = AutoConfig.from_pretrained(str(Path(config_path).parent))
153
+ enc = build_encoder(config, device=dev)
154
+ head = build_head("bigru", d_in=enc.config.hidden_size + feature_dim(feats),
155
+ hidden=hidden).to(dev)
156
+ state = torch.load(weights_path, map_location=dev, weights_only=True)
157
+ enc.load_state_dict(state["encoder"])
158
+ enc.eval()
159
+ head.load_state_dict(state["head_state"])
160
+ head.eval()
161
+ if band:
162
+ from . import band as band_mod
163
+ band_mod.enable(enc)
164
+ autocast = dev.type == "cuda"
165
+ return lambda page: infer_page(enc, head, page, dev, autocast)
htmlsift/extractor.py ADDED
@@ -0,0 +1,91 @@
1
+ """The public extractor: HTML in, main content out. Holds a lazily-loaded Model and
2
+ runs one pipeline per call -- parse, render to lines, score each line, keep the lines
3
+ over threshold, serialize. Mirrors the research inference front-end (render_blocks +
4
+ prep_page + threshold); no sanitize step.
5
+ """
6
+ import numpy as np
7
+
8
+ from .core.prep_page import prep_page
9
+ from .core.prune import prune, to_html
10
+ from .core.render import parse, render_tree
11
+ from .loader import load_model
12
+
13
+ THRESHOLD = 0.5
14
+
15
+
16
+ def _selected_srcs(block_src, probs):
17
+ """DOM elements behind the blocks scoring over threshold (union, in order)."""
18
+ return [el for src, p in zip(block_src, probs, strict=True) if p > THRESHOLD for el in src]
19
+
20
+
21
+ class Extractor:
22
+ """Main-content extractor for one mode. The model loads on the first extract() call
23
+ and is reused, so create one Extractor and call it many times."""
24
+
25
+ def __init__(self, mode="mini", *, bundle_dir=None, threads=None, device=None):
26
+ self.mode = mode
27
+ self._bundle_dir = bundle_dir
28
+ self._threads = threads
29
+ self._device = device
30
+ self._model = None
31
+
32
+ @property
33
+ def model(self):
34
+ if self._model is None:
35
+ self._model = load_model(self.mode, bundle_dir=self._bundle_dir,
36
+ threads=self._threads, device=self._device)
37
+ return self._model
38
+
39
+ def extract(self, html, output="text", with_blocks=False):
40
+ """Extract main content from an HTML string.
41
+
42
+ output="text" joins the selected lines; output="html" prunes the DOM to the
43
+ selected blocks (structure, links, images kept) and serializes. with_blocks=True
44
+ returns (blocks, probs) -- every block and its probability, unthresholded."""
45
+ root, blocks, block_src, probs = self._predict(html)
46
+ if with_blocks:
47
+ return blocks, probs
48
+ if output == "text":
49
+ return "\n".join(b for b, p in zip(blocks, probs, strict=True) if p > THRESHOLD)
50
+ if output == "html":
51
+ selected = _selected_srcs(block_src, probs)
52
+ return to_html(root, selected) if selected else ""
53
+ if output == "markdown":
54
+ selected = _selected_srcs(block_src, probs)
55
+ if not selected:
56
+ return ""
57
+ prune(root, selected)
58
+ lines, _ = render_tree(root, links=True)
59
+ return "\n".join(lines).strip("\n")
60
+ raise ValueError(f"unknown output {output!r}; expected 'text', 'html', or 'markdown'")
61
+
62
+ def _predict(self, html):
63
+ """html -> (root, blocks, block_src, probs). blocks are the rstripped non-empty
64
+ render lines; block_src[i] the DOM elements behind block i. Empty page ->
65
+ (None, [], [], empty array)."""
66
+ if not html or not html.strip():
67
+ return None, [], [], np.empty(0, dtype=np.float32)
68
+ root = parse(html)
69
+ lines, line_src = render_tree(root)
70
+ blocks, block_src = [], []
71
+ for ln, src in zip(lines, line_src, strict=True):
72
+ if ln.strip():
73
+ blocks.append(ln.rstrip())
74
+ block_src.append(src)
75
+ if not blocks:
76
+ return root, [], [], np.empty(0, dtype=np.float32)
77
+ feats = self.model.features(lines, line_src) # on RAW lines
78
+ page = prep_page(self.model.tok, blocks, self.model.window,
79
+ cap=self.model.cap, feats=feats)
80
+ return root, blocks, block_src, self.model.infer_fn(page)
81
+
82
+
83
+ _CACHE = {}
84
+
85
+
86
+ def extract(html, mode="mini", output="text", with_blocks=False):
87
+ """Module-level convenience: extract with a per-mode cached Extractor."""
88
+ ex = _CACHE.get(mode)
89
+ if ex is None:
90
+ ex = _CACHE[mode] = Extractor(mode)
91
+ return ex.extract(html, output=output, with_blocks=with_blocks)