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.
- htmlsift/__init__.py +9 -0
- htmlsift/_artifacts.py +64 -0
- htmlsift/cli.py +29 -0
- htmlsift/core/__init__.py +6 -0
- htmlsift/core/band.py +144 -0
- htmlsift/core/features.py +129 -0
- htmlsift/core/onnx_infer.py +38 -0
- htmlsift/core/prep_page.py +93 -0
- htmlsift/core/prune.py +42 -0
- htmlsift/core/render.py +328 -0
- htmlsift/core/tokenizer.py +38 -0
- htmlsift/core/torch_infer.py +165 -0
- htmlsift/extractor.py +91 -0
- htmlsift/loader.py +82 -0
- htmlsift/py.typed +0 -0
- htmlsift-0.1.0.dist-info/METADATA +89 -0
- htmlsift-0.1.0.dist-info/RECORD +20 -0
- htmlsift-0.1.0.dist-info/WHEEL +4 -0
- htmlsift-0.1.0.dist-info/entry_points.txt +2 -0
- htmlsift-0.1.0.dist-info/licenses/LICENSE +201 -0
htmlsift/core/render.py
ADDED
|
@@ -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)
|
|
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), ) -- 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)
|