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 ADDED
@@ -0,0 +1,9 @@
1
+ """htmlsift -- web main-content extraction (HTML in, clean text/HTML/markdown out).
2
+
3
+ from htmlsift import Extractor, extract
4
+ """
5
+ from .extractor import Extractor, extract
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["Extractor", "extract"]
htmlsift/_artifacts.py ADDED
@@ -0,0 +1,64 @@
1
+ """Where the model bundles live and how the package fetches them.
2
+
3
+ Bundles are published to a public HuggingFace repo and pinned by commit SHA per
4
+ release, so a given htmlsift version always fetches byte-identical files. Fetching
5
+ uses hf_hub_download, which caches to the standard HF cache; a local directory can
6
+ override the fetch during development.
7
+ """
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from huggingface_hub import hf_hub_download
13
+
14
+ REPO = "koivualeksi/htmlsift-release"
15
+ REVISION = "73ac2b978aef356e9e5772640a0c9444f2b67705" # release commit with both mini/
16
+ # and base/ bundles. Pin per release so a version fetches byte-
17
+ # identical files; None (dev only) would track the default branch.
18
+
19
+ # Bundle filenames. TABLE/HEAD are the mini ONNX graphs and must match what
20
+ # tools/release.py publishes (research-side).
21
+ TABLE = "mini-table-int8.onnx"
22
+ HEAD = "mini-head-fABC.onnx"
23
+ TOKENIZER = "tokenizer.json"
24
+ MANIFEST = "manifest.json"
25
+
26
+ # Each mode's bundle as {logical name -> filename}. Remote layout is <mode>/<filename>.
27
+ BUNDLE_FILES = {
28
+ "mini": {"table": TABLE, "head": HEAD, "tokenizer": TOKENIZER, "manifest": MANIFEST},
29
+ "base": {"config": "config.json", "weights": "weights.pt",
30
+ "tokenizer": TOKENIZER, "manifest": MANIFEST},
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class Manifest:
36
+ """The per-model config that travels in the bundle (feats spec + z-score stats +
37
+ sizes), read from manifest.json so the package needs no torch to load it."""
38
+ mode: str
39
+ feats: str | None = None
40
+ hidden: int | None = None
41
+ window: int = 8192
42
+ cap: int = 0
43
+ zscore_mean: list = field(default_factory=list)
44
+ zscore_std: list = field(default_factory=list)
45
+
46
+ @classmethod
47
+ def from_file(cls, path):
48
+ d = json.loads(Path(path).read_text(encoding="utf-8"))
49
+ z = d.get("zscore") or {}
50
+ return cls(mode=d["mode"], feats=d.get("feats"), hidden=d.get("hidden"),
51
+ window=d.get("window", 8192), cap=d.get("cap", 0),
52
+ zscore_mean=z.get("mean", []), zscore_std=z.get("std", []))
53
+
54
+
55
+ def resolve_bundle(mode, local_dir=None):
56
+ """{logical name -> local file path} for `mode`'s bundle. A local_dir reads the
57
+ files straight off disk (development); otherwise each is pulled from the release
58
+ repo at the pinned revision and cached by huggingface_hub."""
59
+ files = BUNDLE_FILES[mode]
60
+ if local_dir:
61
+ base = Path(local_dir)
62
+ return {name: base / fn for name, fn in files.items()}
63
+ return {name: Path(hf_hub_download(REPO, f"{mode}/{fn}", revision=REVISION))
64
+ for name, fn in files.items()}
htmlsift/cli.py ADDED
@@ -0,0 +1,29 @@
1
+ """`htmlsift` command line: prefetch model bundles into the Hugging Face cache.
2
+
3
+ htmlsift download mini # fetch + cache the mini bundle
4
+ htmlsift download base # fetch + cache the base bundle
5
+ """
6
+ import argparse
7
+
8
+ from ._artifacts import BUNDLE_FILES, resolve_bundle
9
+
10
+
11
+ def _download(mode):
12
+ paths = resolve_bundle(mode) # hf_hub_download each file -> cached
13
+ print(f"{mode}: cached {len(paths)} files")
14
+ for name, p in paths.items():
15
+ print(f" {name:10} {p}")
16
+
17
+
18
+ def main(argv=None):
19
+ ap = argparse.ArgumentParser(prog="htmlsift")
20
+ sub = ap.add_subparsers(dest="cmd", required=True)
21
+ dl = sub.add_parser("download", help="prefetch a model bundle into the HF cache")
22
+ dl.add_argument("mode", choices=list(BUNDLE_FILES))
23
+ args = ap.parse_args(argv)
24
+ if args.cmd == "download":
25
+ _download(args.mode)
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,6 @@
1
+ """Vendored inference engine, relicensed Apache-2.0 from htmlsift-research/core.
2
+
3
+ Kept as a faithful copy so each module diffs cleanly against its research original; the
4
+ only intended change is render.py's output-only ``links=`` extension. See packaging_plan.md
5
+ sections 4-5.
6
+ """
htmlsift/core/band.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ Chunked band attention for the encoder's sliding-attention layers -- a speed
3
+ optimization that changes nothing about the output.
4
+
5
+ transformers builds the sliding window as a dense (B, 1, L, L) boolean mask and
6
+ hands it to scaled_dot_product_attention, whose CPU kernel scores the whole
7
+ L x L matrix and then discards the out-of-band entries -- so a sliding layer
8
+ costs as much as a full one and local_attention buys nothing. This computes the
9
+ same numbers in O(L*w) by cutting the sequence into chunks and letting each
10
+ query chunk attend only to the key slice it can reach.
11
+
12
+ The arithmetic is unchanged: the per-chunk predicate is exactly
13
+ abs(q_idx - kv_idx) <= config.sliding_window, the same one transformers' sliding
14
+ mask applies. So this is a speed-only optimization gated on numeric identity, not on
15
+ a val score -- it computes the same numbers as stock sliding attention, only faster.
16
+
17
+ import core.band
18
+ core.band.enable(encoder) # in place, idempotent
19
+ """
20
+ import torch
21
+ import torch.nn.functional as F
22
+ from transformers.integrations.sdpa_attention import sdpa_attention_forward
23
+ from transformers.masking_utils import ALL_MASK_ATTENTION_FUNCTIONS, sdpa_mask
24
+ from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
25
+
26
+ IMPL = "sdpa_band"
27
+ CHUNK = 128
28
+ # Below this the reshaping costs more than the scores it saves, and the dense
29
+ # band mask is only a few hundred KB -- exactly what transformers would build.
30
+ MIN_CHUNKED = 4 * CHUNK
31
+ _NO_MASK = {"full_attention": None, "sliding_attention": None}
32
+
33
+
34
+ def _dense_band_mask(n_tok, half, device):
35
+ """(1, 1, L, L) bool -- the sliding mask transformers would have built."""
36
+ i = torch.arange(n_tok, device=device)
37
+ return ((i[None, :] - i[:, None]).abs() <= half)[None, None]
38
+
39
+
40
+ def _chunk_mask(chunk, half, n_chunks, n_tok, device):
41
+ """(n_chunks, 1, chunk, chunk + 2*half) bool: inside the band AND a real
42
+ token. A query row past the sequence end has no valid key and softmax over an
43
+ all-False row is NaN, so each dead row gets its own zero slot; those rows are
44
+ sliced off before the result returns."""
45
+ span = chunk + 2 * half
46
+ j = torch.arange(chunk, device=device)
47
+ t = torch.arange(span, device=device)
48
+ band = (t[None, :] - half - j[:, None]).abs() <= half
49
+ c = torch.arange(n_chunks, device=device)
50
+ kpos = c[:, None] * chunk - half + t[None, :]
51
+ m = band[None] & ((kpos >= 0) & (kpos < n_tok))[:, None, :]
52
+ dead = (c[:, None] * chunk + j[None, :]) >= n_tok
53
+ if bool(dead.any()):
54
+ ci, ji = torch.nonzero(dead, as_tuple=True)
55
+ m[ci, ji, half + ji] = True
56
+ return m[:, None]
57
+
58
+
59
+ def band_sdpa_forward(module, query, key, value, attention_mask, dropout=0.0,
60
+ scaling=None, sliding_window=None, **kwargs):
61
+ """Drop-in for sdpa_attention_forward. The band path runs only for a sliding
62
+ layer with nothing to pad; a full layer (sliding_window is None) or any real
63
+ padding mask is delegated to stock sdpa, unchanged."""
64
+ if sliding_window is None or attention_mask is not None:
65
+ return sdpa_attention_forward(module, query, key, value, attention_mask,
66
+ dropout=dropout, scaling=scaling,
67
+ sliding_window=sliding_window, **kwargs)
68
+
69
+ half = int(module.config.sliding_window)
70
+ B, H, L, D = query.shape
71
+
72
+ if L <= MIN_CHUNKED:
73
+ out = F.scaled_dot_product_attention(
74
+ query, key, value, attn_mask=_dense_band_mask(L, half, query.device),
75
+ dropout_p=dropout, scale=scaling, is_causal=False)
76
+ return out.transpose(1, 2).contiguous(), None
77
+
78
+ chunk = max(CHUNK, half)
79
+ span = chunk + 2 * half
80
+ pad = (-L) % chunk
81
+ n = (L + pad) // chunk
82
+
83
+ q = F.pad(query, (0, 0, 0, pad)).view(B, H, n, chunk, D)
84
+ q = q.permute(0, 2, 1, 3, 4).reshape(B * n, H, chunk, D)
85
+ kv = []
86
+ for x in (key, value):
87
+ x = F.pad(x, (0, 0, half, pad + half)).unfold(2, span, chunk)
88
+ kv.append(x.permute(0, 2, 1, 4, 3).reshape(B * n, H, span, D))
89
+
90
+ m = _chunk_mask(chunk, half, n, L, query.device)
91
+ if B > 1:
92
+ m = m.repeat(B, 1, 1, 1)
93
+
94
+ out = F.scaled_dot_product_attention(q, kv[0], kv[1], attn_mask=m,
95
+ dropout_p=dropout, scale=scaling,
96
+ is_causal=False)
97
+ out = out.view(B, n, H, chunk, D).permute(0, 2, 1, 3, 4)
98
+ out = out.reshape(B, H, n * chunk, D)[:, :, :L]
99
+ return out.transpose(1, 2).contiguous(), None
100
+
101
+
102
+ def register():
103
+ ALL_ATTENTION_FUNCTIONS.register(IMPL, band_sdpa_forward)
104
+ # Only reached on the fallback path (a real padding mask), but a missing key
105
+ # here would be a KeyError deep inside mask construction.
106
+ ALL_MASK_ATTENTION_FUNCTIONS.register(IMPL, sdpa_mask)
107
+
108
+
109
+ def _no_mask_hook(module, args, kwargs):
110
+ """Replace an all-ones 2D mask with the "no mask" mapping so the model never
111
+ materialises the dense (1, 1, L, L) sliding mask -- the very thing the band
112
+ path exists to avoid. ModernBertModel.forward uses a mask dict verbatim when
113
+ given one, so {'full_attention': None, 'sliding_attention': None} makes every
114
+ layer receive None and band_sdpa_forward take the fast path. Anything that is
115
+ NOT an all-ones 2D mask (real padding) is left alone: the dense mask is built
116
+ and band_sdpa_forward falls back to stock sdpa. The fast path can therefore
117
+ only run when there is nothing to pad.
118
+
119
+ A forward_pre_hook, NOT a wrapped encoder.forward, and that is load-bearing.
120
+ torch.ao.quantization.quantize_dynamic returns a deep copy; a closure stored
121
+ as encoder.forward keeps pointing at the original fp32 module, so enable()
122
+ then quantize would silently run fp32 while reporting every layer quantized.
123
+ Hooks live in _forward_pre_hooks, are copied with the module, and take module
124
+ as an argument instead of capturing it.
125
+ """
126
+ am = kwargs.get("attention_mask")
127
+ if am is None or (torch.is_tensor(am) and am.dim() == 2 and bool(am.all())):
128
+ kwargs["attention_mask"] = _NO_MASK
129
+ return args, kwargs
130
+ return None
131
+
132
+
133
+ def enable(encoder):
134
+ """Switch a ModernBERT encoder to band attention, in place. Idempotent."""
135
+ register()
136
+ encoder.config._attn_implementation = IMPL
137
+ for m in encoder.modules(): # layers may hold their own config copy
138
+ cfg = getattr(m, "config", None)
139
+ if cfg is not None:
140
+ cfg._attn_implementation = IMPL
141
+ if not getattr(encoder, "_band_enabled", False):
142
+ encoder.register_forward_pre_hook(_no_mask_hook, with_kwargs=True)
143
+ encoder._band_enabled = True
144
+ return encoder
@@ -0,0 +1,129 @@
1
+ """Structural block features for the table-embedding arm (mini): the signal a
2
+ mean-pooled bag of token embeddings cannot carry, and nothing the rendered text
3
+ already shows.
4
+
5
+ Computed from render_tree's line_src (the DOM elements behind each block) -- no
6
+ second parse, no marker injection, no change to the renderer. Booleans for
7
+ presence; only the two genuine scalars (depth, log_chars) are z-scored, using the
8
+ stats shipped in the model bundle.
9
+
10
+ A column earns its place under one rule: it is absent from the tokenized block text
11
+ AND not recoverable by the BiGRU. Tag structure is flattened by markdown, and any
12
+ marker that survives is diluted by the per-block mean-pool; DOM depth and
13
+ link-ancestry never appear in the rendered text at all (the renderer prints an <a>
14
+ as bare text); block length is destroyed the moment the tokens are averaged.
15
+
16
+ This module is the feature manifest: column order and the group map live here and
17
+ nowhere else.
18
+ """
19
+
20
+ import math
21
+
22
+ import numpy as np
23
+
24
+ # Group A -- ancestor-or-self tag presence, 30 booleans. Semantic containers that
25
+ # markdown flattens away, plus block tags whose surviving text marker the mean-pool
26
+ # dilutes.
27
+ TAG_VOCAB = [
28
+ "main", "article", "section", "nav", "header", "footer", "aside",
29
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "ul", "ol", "dl",
30
+ "table", "tr", "td", "th", "blockquote", "pre", "code",
31
+ "figure", "figcaption", "form", "button", "div",
32
+ ]
33
+
34
+ GROUPS = {
35
+ "A": [f"tag_{t}" for t in TAG_VOCAB], # ancestor/self tag presence (boolean)
36
+ "B": ["depth", "has_link"], # DOM nesting depth (scalar); an <a> ancestor (boolean)
37
+ "C": ["log_chars"], # block length -- what the mean-pool averages away
38
+ }
39
+
40
+ FEATURE_NAMES = [name for g in ("A", "B", "C") for name in GROUPS[g]]
41
+
42
+ # The only unbounded columns; z-scored with the bundle's stats. Booleans and
43
+ # already-bounded columns stay raw.
44
+ ZSCORE_COLS = ["depth", "log_chars"]
45
+
46
+ _ORDER = ("A", "B", "C")
47
+ _VOCAB = set(TAG_VOCAB)
48
+
49
+
50
+ def collect_features(lines, line_src, groups=_ORDER):
51
+ """(lines, line_src) from render_tree -> [n_blocks, K] float32, one row per
52
+ non-empty line (the block unit). Columns are the requested groups in manifest
53
+ order; only those groups are computed, so a text-only arm passes groups=() and
54
+ pays nothing. The ancestor chain of each element is cached and shared across
55
+ blocks (below)."""
56
+ groups = [g for g in _ORDER if g in groups]
57
+ cols = [c for g in groups for c in GROUPS[g]]
58
+ col = {c: i for i, c in enumerate(cols)}
59
+ blocks = [(ln, src) for ln, src in zip(lines, line_src, strict=True) if ln.strip()]
60
+ out = np.zeros((len(blocks), len(cols)), dtype=np.float32)
61
+ need_a, need_b, need_c = "A" in groups, "B" in groups, "C" in groups
62
+
63
+ # element -> (vocab tags, depth, link) for its ancestor-or-self chain, computed
64
+ # once and reused. Every block re-reaches the same container chain to root, so
65
+ # caching collapses that repetition: the walk stops at the first cached ancestor.
66
+ # Keyed by the element object -- the cache holds the reference, which keeps the
67
+ # lxml proxy (and thus its identity) stable for the call.
68
+ chain = {}
69
+
70
+ def chain_info(e):
71
+ stack = []
72
+ node = e
73
+ while node is not None and isinstance(node.tag, str) and node not in chain:
74
+ stack.append(node)
75
+ node = node.getparent()
76
+ if node is not None and isinstance(node.tag, str):
77
+ tags, depth, link = chain[node]
78
+ else:
79
+ tags, depth, link = frozenset(), 0, False
80
+ for nd in reversed(stack): # fold from cached base to e
81
+ t = nd.tag
82
+ if need_a and t in _VOCAB and t not in tags:
83
+ tags = tags | {t}
84
+ depth += 1
85
+ link = link or t == "a"
86
+ chain[nd] = (tags, depth, link)
87
+ return tags, depth, link
88
+
89
+ for bi, (text, src) in enumerate(blocks):
90
+ if need_a or need_b:
91
+ btags, depth_sum, n_src, has_link = set(), 0, 0, False
92
+ for e in src:
93
+ et, ed, el = chain_info(e)
94
+ btags |= et
95
+ depth_sum += ed
96
+ n_src += 1
97
+ has_link = has_link or el
98
+ if need_a:
99
+ for t in btags:
100
+ out[bi, col[f"tag_{t}"]] = 1.0
101
+ if need_b:
102
+ out[bi, col["depth"]] = depth_sum / n_src if n_src else 0.0
103
+ out[bi, col["has_link"]] = float(has_link)
104
+ if need_c:
105
+ out[bi, col["log_chars"]] = math.log1p(len(text))
106
+ return out
107
+
108
+
109
+ def feature_dim(groups):
110
+ """Number of feature columns a group selection produces (None/"" -> 0). Sizes
111
+ the head's d_in without hardcoding K."""
112
+ return sum(len(GROUPS[g]) for g in _ORDER if groups and g in groups)
113
+
114
+
115
+ def _zscore_idx(groups):
116
+ """Column positions of the unbounded scalars (ZSCORE_COLS) within a group
117
+ selection's layout -- lets the loader pair the bundle's mean/std with the right
118
+ columns for a given feats group."""
119
+ cols = [c for g in _ORDER if g in groups for c in GROUPS[g]]
120
+ return [i for i, c in enumerate(cols) if c in ZSCORE_COLS]
121
+
122
+
123
+ def apply_zscore(fa, stats):
124
+ """A copy of a page's [n_blocks, K] array with the z-scored columns normalized by
125
+ the bundle's (idx, mean, std) stats; booleans and bounded columns untouched."""
126
+ idx, mean, std = stats
127
+ out = fa.copy()
128
+ out[:, idx] = (out[:, idx] - mean) / std
129
+ return out
@@ -0,0 +1,38 @@
1
+ """Torch-free onnxruntime forward for the mini model (int8 table + BiGRU head).
2
+
3
+ Embed the token ids through the int8 table graph, mean-pool by block membership,
4
+ concat any structural features, run the head graph, sigmoid. numpy + onnxruntime only
5
+ -- no torch. Lifted unchanged from the research export so it stays bit-identical to the
6
+ graphs' parity-gated reference forward.
7
+ """
8
+ import numpy as np
9
+ import onnxruntime as ort
10
+
11
+
12
+ def pool(emb, members):
13
+ """Token embeddings emb [T, H] -> per-block mean vectors [n, H]. A token-less block
14
+ pools to zeros (sum of nothing / 1)."""
15
+ pooled = np.zeros((len(members), emb.shape[1]), dtype=np.float32)
16
+ for i, mem in enumerate(members):
17
+ if mem:
18
+ pooled[i] = emb[mem].mean(0)
19
+ return pooled
20
+
21
+
22
+ def load_mini(table_path, head_path, threads=None):
23
+ """The two mini graphs -> infer_fn(page) -> per-block probs (np.float32): the
24
+ (page -> probs) contract the extractor consumes."""
25
+ opts = ort.SessionOptions()
26
+ if threads:
27
+ opts.intra_op_num_threads, opts.inter_op_num_threads = threads, 1
28
+ table = ort.InferenceSession(str(table_path), opts, providers=["CPUExecutionProvider"])
29
+ head = ort.InferenceSession(str(head_path), opts, providers=["CPUExecutionProvider"])
30
+
31
+ def infer_fn(page):
32
+ emb = table.run(["out"], {"ids": page["ids"].astype(np.int64)})[0] # [T, H]
33
+ x = pool(emb, page["members"]) # [n, H]
34
+ if page.get("feats") is not None:
35
+ x = np.concatenate([x, page["feats"].astype(np.float32)], 1) # [n, H+K]
36
+ logits = head.run(["logits"], {"x": x[None]})[0][0] # [n]
37
+ return (1.0 / (1.0 + np.exp(-logits))).astype(np.float32)
38
+ return infer_fn
@@ -0,0 +1,93 @@
1
+ """Page layout for inference: tokenize a page's blocks once, map every token back
2
+ to the block it came from, and (for the windowed encoder path) plan the token
3
+ windows. No gradients, no encoder -- deterministic shaping the forwards consume.
4
+ """
5
+
6
+ import numpy as np
7
+
8
+
9
+ def block_spans(blocks):
10
+ """Char span [start, end) of each block within "\\n".join(blocks)."""
11
+ spans, pos = [], 0
12
+ for b in blocks:
13
+ spans.append((pos, pos + len(b)))
14
+ pos += len(b) + 1 # +1 for the joining newline
15
+ return spans
16
+
17
+
18
+ def page_members(enc, blocks):
19
+ """Per block, the indices of the tokens whose characters fall inside it.
20
+
21
+ `enc` is a fast-tokenizer output carrying offset_mapping and
22
+ special_tokens_mask. A single cursor advances through the non-special tokens as
23
+ blocks are consumed left to right, so the whole map is one linear pass. A token
24
+ joins a block when it overlaps the block's characters; the `end > bs` guard
25
+ drops zero-width tokens (e.g. (0,0)) while keeping a subword that straddles the
26
+ joining newline with the block it reaches into. A block with no tokens of its
27
+ own gets [] and pools to a zero vector, so members stays aligned with blocks.
28
+ """
29
+ offsets = enc["offset_mapping"]
30
+ special = enc["special_tokens_mask"]
31
+ tok_idx = [i for i in range(len(offsets)) if not special[i]]
32
+ members = []
33
+ ti = 0
34
+ for bs, be in block_spans(blocks):
35
+ while ti < len(tok_idx) and offsets[tok_idx[ti]][1] <= bs:
36
+ ti += 1
37
+ j, mem = ti, []
38
+ while j < len(tok_idx) and offsets[tok_idx[j]][0] < be:
39
+ if offsets[tok_idx[j]][1] > bs:
40
+ mem.append(tok_idx[j])
41
+ j += 1
42
+ members.append(mem)
43
+ return members
44
+
45
+
46
+ def window_starts(n, window, stride=None):
47
+ """Start offsets of the windows tiling n tokens (n > window). stride defaults to
48
+ window // 2 (50% overlap); the last window snaps back to n - window so the tail
49
+ is always fully covered. Shared with the encoder stitch (torch_infer.pool_page),
50
+ so window boundaries are defined in one place."""
51
+ stride = stride or window // 2
52
+ starts = list(range(0, n - window + 1, stride))
53
+ if not starts:
54
+ starts = [0]
55
+ if starts[-1] != n - window:
56
+ starts.append(n - window)
57
+ return starts
58
+
59
+
60
+ def apply_cap(ids, members, k):
61
+ """Keep each block's first k member tokens, dropping the rest from the sequence
62
+ -- a per-block width cap. Tokens belonging to no block (specials, the newline
63
+ joins) are always kept. Members are renumbered onto the compacted ids."""
64
+ keep = np.ones(len(ids), dtype=bool)
65
+ for mem in members:
66
+ if len(mem) > k:
67
+ keep[np.asarray(mem[k:], dtype=np.int64)] = False
68
+ newpos = np.cumsum(keep) - 1
69
+ return ids[keep], [[int(newpos[t]) for t in mem[:k]] for mem in members]
70
+
71
+
72
+ def prep_page(tok, blocks, window, stride=None, cap=0, feats=None, tid=None):
73
+ """Blocks -> the page dict the forwards consume: token ids, per-block members,
74
+ optional structural features, and the window plan. The page carries its own
75
+ window/stride so the encoder stitch cannot drift. truncation=False -- the page is
76
+ windowed by the encoder, never truncated by the tokenizer. cap>0 applies the
77
+ per-block width cap first.
78
+
79
+ feats, when given, is an [n_blocks, K] array (already z-scored on the training
80
+ stats, aligned with blocks) carried through to the head concat; feats=None runs
81
+ text-only. The width cap drops tokens, not blocks, so feats stays aligned."""
82
+ enc = tok("\n".join(blocks), add_special_tokens=True,
83
+ return_offsets_mapping=True, return_special_tokens_mask=True,
84
+ return_tensors=None, truncation=False)
85
+ ids = np.array(enc["input_ids"], dtype=np.int64)
86
+ members = page_members(enc, blocks)
87
+ if cap:
88
+ ids, members = apply_cap(ids, members, cap)
89
+ if feats is not None:
90
+ feats = np.asarray(feats, dtype=np.float32)
91
+ assert feats.shape[0] == len(members), (feats.shape[0], len(members))
92
+ return {"tid": tid, "ids": ids, "members": members, "feats": feats,
93
+ "window": window, "stride": stride}
htmlsift/core/prune.py ADDED
@@ -0,0 +1,42 @@
1
+ """Prune a parsed DOM to the elements behind the selected blocks, then serialize.
2
+
3
+ The renderer records, per rendered line, the DOM elements that produced it (line_src).
4
+ Given the elements behind the KEPT lines, keep exactly those, their ancestors (the
5
+ structural path to root) and their descendants (inline links/images inside a kept block),
6
+ and drop everything else. lxml only -- no html2text.
7
+ """
8
+ from lxml import html as lxml_html
9
+
10
+
11
+ def keep_set(elements):
12
+ """The nodes to keep: each element, its ancestors, and its descendants. Holding the
13
+ proxies in the set keeps their lxml identity stable for the prune walk."""
14
+ keep = set()
15
+ for el in elements:
16
+ if el in keep:
17
+ continue
18
+ keep.add(el)
19
+ keep.update(el.iterancestors())
20
+ keep.update(el.iterdescendants())
21
+ return keep
22
+
23
+
24
+ def prune(root, elements):
25
+ """Remove from root every element not in keep_set(elements); returns root (mutated)."""
26
+ keep = keep_set(elements)
27
+
28
+ def walk(el):
29
+ for child in list(el):
30
+ if child in keep:
31
+ walk(child)
32
+ else:
33
+ el.remove(child)
34
+
35
+ walk(root)
36
+ return root
37
+
38
+
39
+ def to_html(root, elements):
40
+ """Prune root to the selected elements and serialize to an HTML string."""
41
+ prune(root, elements)
42
+ return lxml_html.tostring(root, encoding="unicode")