htmlsift 0.2.0__tar.gz → 0.2.1__tar.gz

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 (63) hide show
  1. htmlsift-0.2.1/.github/workflows/ci.yml +50 -0
  2. {htmlsift-0.2.0 → htmlsift-0.2.1}/CHANGELOG.md +40 -1
  3. {htmlsift-0.2.0 → htmlsift-0.2.1}/PKG-INFO +27 -3
  4. {htmlsift-0.2.0 → htmlsift-0.2.1}/README.md +39 -2
  5. {htmlsift-0.2.0 → htmlsift-0.2.1}/README.pypi.md +26 -2
  6. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/__init__.py +1 -1
  7. htmlsift-0.2.1/src/htmlsift/_title.py +63 -0
  8. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/cli.py +38 -1
  9. htmlsift-0.2.1/src/htmlsift/core/onnx_infer.py +55 -0
  10. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/render.py +57 -13
  11. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/extractor.py +63 -15
  12. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/loader.py +11 -0
  13. htmlsift-0.2.1/tests/data/sample.extracted.txt +12 -0
  14. htmlsift-0.2.1/tests/test_api.py +223 -0
  15. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_cli.py +50 -4
  16. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_decode.py +16 -4
  17. htmlsift-0.2.1/tests/test_onnx_infer.py +110 -0
  18. htmlsift-0.2.1/tests/test_render_plain.py +72 -0
  19. htmlsift-0.2.1/tests/test_title.py +139 -0
  20. htmlsift-0.2.0/.github/workflows/ci.yml +0 -24
  21. htmlsift-0.2.0/src/htmlsift/core/onnx_infer.py +0 -38
  22. htmlsift-0.2.0/tests/data/sample.extracted.txt +0 -15
  23. htmlsift-0.2.0/tests/test_api.py +0 -64
  24. {htmlsift-0.2.0 → htmlsift-0.2.1}/.gitattributes +0 -0
  25. {htmlsift-0.2.0 → htmlsift-0.2.1}/.github/workflows/publish.yml +0 -0
  26. {htmlsift-0.2.0 → htmlsift-0.2.1}/.gitignore +0 -0
  27. {htmlsift-0.2.0 → htmlsift-0.2.1}/AGENTS.md +0 -0
  28. {htmlsift-0.2.0 → htmlsift-0.2.1}/LICENSE +0 -0
  29. {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/annotation-example.svg +0 -0
  30. {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/architecture.svg +0 -0
  31. {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/concept.svg +0 -0
  32. {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/cpu_speed_accuracy.svg +0 -0
  33. {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/data-efficiency.svg +0 -0
  34. {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/gpu_speed_accuracy_lite.svg +0 -0
  35. {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/signals.svg +0 -0
  36. {htmlsift-0.2.0 → htmlsift-0.2.1}/pyproject.toml +0 -0
  37. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/_artifacts.py +0 -0
  38. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/_decode.py +0 -0
  39. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/_links.py +0 -0
  40. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/__init__.py +0 -0
  41. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/band.py +0 -0
  42. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/features.py +0 -0
  43. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/prep_page.py +0 -0
  44. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/prune.py +0 -0
  45. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/tokenizer.py +0 -0
  46. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/torch_infer.py +0 -0
  47. {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/py.typed +0 -0
  48. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/__init__.py +0 -0
  49. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/conftest.py +0 -0
  50. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/article_fi.html +0 -0
  51. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/article_ja.html +0 -0
  52. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.extracted.html +0 -0
  53. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.extracted.md +0 -0
  54. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.html +0 -0
  55. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.rendered.txt +0 -0
  56. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_extract_golden.py +0 -0
  57. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_features.py +0 -0
  58. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_links.py +0 -0
  59. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_manifest.py +0 -0
  60. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_prep_page.py +0 -0
  61. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_prune.py +0 -0
  62. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_render_golden.py +0 -0
  63. {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_render_links.py +0 -0
@@ -0,0 +1,50 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ schedule:
8
+ - cron: "17 4 * * 1" # Mondays 04:17 UTC
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ python-version: ["3.10", "3.13"]
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+ - name: Install (base deps only, no torch)
27
+ run: pip install -e . ruff pytest
28
+ - name: Show installed versions
29
+ run: pip list
30
+ - name: Lint
31
+ run: ruff check src tests
32
+ - name: Read pinned bundle revision
33
+ id: rev
34
+ run: |
35
+ rev=$(sed -n 's/^REVISION = "\([0-9a-f]\{40\}\)".*/\1/p' src/htmlsift/_artifacts.py)
36
+ test -n "$rev" || { echo "REVISION not found in _artifacts.py"; exit 1; }
37
+ echo "rev=$rev" >> "$GITHUB_OUTPUT"
38
+ - name: Cache mini bundle
39
+ uses: actions/cache@v4
40
+ with:
41
+ path: ~/.cache/huggingface/hub
42
+ key: htmlsift-mini-${{ steps.rev.outputs.rev }}
43
+ - name: Fetch mini bundle (no network on a cache hit)
44
+ run: |
45
+ htmlsift download mini
46
+ dir="$HOME/.cache/huggingface/hub/models--koivualeksi--htmlsift-release/snapshots/${{ steps.rev.outputs.rev }}/mini"
47
+ test -f "$dir/manifest.json" || { echo "bundle not at $dir"; exit 1; }
48
+ echo "HTMLSIFT_BUNDLE=$dir" >> "$GITHUB_ENV"
49
+ - name: Test (model goldens included)
50
+ run: pytest -q -rs
@@ -6,6 +6,44 @@ All notable changes to htmlsift are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-11-08
10
+
11
+ ### Added
12
+ - `htmlsift --version` prints the htmlsift and Python versions and the installed
13
+ backends: onnxruntime for `mini`, torch and transformers for `base`.
14
+ - `drop_title=True` in `extract()` and `--drop-title` in the CLI leave out the page title
15
+ that starts the content: the first selected line, when it comes from an `h1`–`h6` or
16
+ `p` element holding only that line and matches the page's `<title>` or `og:title`.
17
+ Applies to `text`, `html` and `markdown`. Off by default.
18
+
19
+ ### Changed
20
+ - With w3lib 2.6 or newer, the five bytes windows-1252 leaves undefined (`0x81`, `0x8D`,
21
+ `0x8F`, `0x90`, `0x9D`) decode to C1 control characters, as in browsers, instead of
22
+ `U+FFFD`.
23
+ - `output="text"` (the default) returns plain text, as documented. Heading `#`, emphasis,
24
+ inline-code and strikethrough markers, `>` quote prefixes, `* * *` rules and code-fence
25
+ lines are no longer in it; list bullets, ordered-list numbers and table rows stay.
26
+ Literal `#`, `*`, `_` or `|` in the page text are kept. The selected content is the
27
+ same as in 0.2.0, and the model input and predictions are unchanged.
28
+ - `text` output renders the page a second time, without markers: about 3 ms per page
29
+ more on one CPU core (mini, measured on CommonCrawl pages).
30
+ - The lines as the model scored them, markers included, are still available through
31
+ `with_blocks=True`.
32
+
33
+ ### Fixed
34
+ - `output="text"` no longer contains lines made only of invisible characters (zero-width
35
+ spaces and joiners, byte order marks, soft hyphens, control characters), which pages
36
+ use as spacers.
37
+ - `mini` memory on very long pages. The embedding step no longer grows with page length
38
+ (a 3M-token page peaked at 39 GB, now 1.2 GB), and pages estimated above 100 MB hand
39
+ their memory back to the system when they finish instead of keeping it for the life
40
+ of the process. Output is unchanged; model time is about 15 % lower on CommonCrawl
41
+ pages.
42
+ - `mode="base"` without torch or transformers installed raised a bare
43
+ `No module named 'torch'`, and only after downloading the ~1 GB base bundle. It now
44
+ raises `ImportError` before any download, naming the missing packages and
45
+ `pip install "htmlsift[full]"`. The CLI prints the same message.
46
+
9
47
  ## [0.2.0] - 2026-11-02
10
48
 
11
49
  ### Added
@@ -66,7 +104,8 @@ Documentation and packaging metadata only — no code or model changes since 0.1
66
104
  warning on CPU fallback.
67
105
  - Model bundles fetched from the Hugging Face Hub, pinned to a fixed commit revision.
68
106
 
69
- [Unreleased]: https://github.com/koivualeksi/htmlsift/compare/v0.2.0...HEAD
107
+ [Unreleased]: https://github.com/koivualeksi/htmlsift/compare/v0.2.1...HEAD
108
+ [0.2.1]: https://github.com/koivualeksi/htmlsift/compare/v0.2.0...v0.2.1
70
109
  [0.2.0]: https://github.com/koivualeksi/htmlsift/compare/v0.1.1...v0.2.0
71
110
  [0.1.1]: https://github.com/koivualeksi/htmlsift/compare/v0.1.0...v0.1.1
72
111
  [0.1.0]: https://github.com/koivualeksi/htmlsift/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: htmlsift
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Main-content extraction for web pages
5
5
  Project-URL: Repository, https://github.com/koivualeksi/htmlsift
6
6
  Project-URL: Research, https://github.com/koivualeksi/htmlsift-research
@@ -82,6 +82,12 @@ Requires Python 3.10+. Model files download from the Hugging Face Hub on first u
82
82
  cached; run `htmlsift download <mode>` to prefetch, or set `HF_HUB_OFFLINE=1` to run from
83
83
  cache only.
84
84
 
85
+ ### Memory (`mini`)
86
+
87
+ ~340 MB once loaded; 99 % of CommonCrawl pages need under 50 MB more. Memory grows with
88
+ page length (~21 KB per rendered line), so a very large page can exhaust RAM on a 1–2 GB
89
+ machine. Cap the input size before calling `extract()`.
90
+
85
91
  ## Usage
86
92
 
87
93
  ```python
@@ -112,6 +118,14 @@ is honoured):
112
118
  ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
113
119
  ```
114
120
 
121
+ Leave out the page title that starts the content, as pretraining text pipelines usually do
122
+ (the first line, when it is an `h1`–`h6` or `p` matching the page's `<title>` or
123
+ `og:title`; kept by default):
124
+
125
+ ```python
126
+ ex.extract(html, drop_title=True)
127
+ ```
128
+
115
129
  The larger model, from `htmlsift[full]`:
116
130
 
117
131
  ```python
@@ -125,19 +139,29 @@ ex = Extractor("base", device="cuda") # require the GPU
125
139
 
126
140
  | `output` | what you get |
127
141
  |---|---|
128
- | `text` (default) | selected content as plain text |
142
+ | `text` (default) | selected content as plain text: no heading, emphasis, code, quote, rule or fence markers; list bullets, ordered-list numbers and table rows (`\| a \| b \|`) stay |
129
143
  | `html` | selected DOM, with structure / links / images kept |
130
144
  | `markdown` | clean markdown, links and images preserved |
131
145
 
146
+ The lines exactly as the model scored them, markdown markers included, come from
147
+ `with_blocks=True`:
148
+
149
+ ```python
150
+ blocks, probs = ex.extract(html, with_blocks=True)
151
+ scored = "\n".join(b for b, p in zip(blocks, probs) if p > 0.5)
152
+ ```
153
+
132
154
  ## Command line
133
155
 
134
156
  ```bash
135
157
  htmlsift extract page.html # main content of one page
136
158
  curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
137
159
  htmlsift extract "crawl/*.html" --output markdown > out.jsonl # one JSON line per file
160
+ htmlsift --version # htmlsift, Python and backend versions
138
161
  ```
139
162
 
140
- Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--jsonl` (records
163
+ Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--drop-title`,
164
+ `--jsonl` (records
141
165
  even for a single file). Failed files become `{"path", "error"}` records and the exit code
142
166
  is 1. Output is utf-8.
143
167
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Main-content extraction for web pages: hand it a raw HTML string and get back the main content
4
4
  - nav, ads, cookie banners, and footers stripped
5
- - outputs raw text, **HTML**, or **markdown**.
5
+ - outputs plain text, **HTML**, or **markdown**.
6
6
 
7
7
  Two models shipped, both trained on [WebMainBench][wmb] (WMB) — 7,825 labelled pages, custom split
8
8
  6,548 train / 732 val / 545 test:
@@ -73,6 +73,17 @@ Requires Python 3.10+. Model files download from the Hugging Face Hub on first u
73
73
  cached; prefetch with `htmlsift download mini` (or `base`), or set `HF_HUB_OFFLINE=1` to run
74
74
  from cache only.
75
75
 
76
+ ### Memory (`mini`)
77
+
78
+ - **Fixed:** ~340 MB once the model is loaded.
79
+ - **Per page:** 99 % of CommonCrawl pages need under 50 MB on top of that.
80
+ - **Long pages:** memory grows with the page, about 21 KB per rendered line plus ~0.2 KB
81
+ per token. The largest page in an 8,820-page CommonCrawl sample needed ~0.8 GB.
82
+ - **Release:** a page estimated above 100 MB hands its memory back to the system when it
83
+ finishes, so one large page doesn't leave a long-running worker big.
84
+ - **Small machines:** page size has no built-in limit, so a very large page can exhaust
85
+ RAM on a 1–2 GB machine. Cap the input size before calling `extract()`.
86
+
76
87
  ## Usage
77
88
 
78
89
  ```python
@@ -122,6 +133,22 @@ The page's first `<base href>` is resolved against `base_url`, as a browser does
122
133
  are. Malformed links are left unchanged. `text` output has no links, so it ignores
123
134
  `base_url`.
124
135
 
136
+ ### Page title
137
+
138
+ Most pages start their main content with the page title. `drop_title=True` leaves it out,
139
+ as text extractors for pretraining corpora usually do (they keep the title as metadata):
140
+
141
+ ```python
142
+ ex.extract(html, drop_title=True)
143
+ ```
144
+
145
+ The first selected line is dropped when it comes from an `h1`–`h6` or `p` element whose
146
+ whole text is that line, and it matches the page's `<title>` or `og:title` (case and
147
+ punctuation ignored, similarity at least 0.8 against the whole title or a part split at
148
+ ` | `, ` - `, ` – `, ` : ` and similar separators). Any other first line is kept. It
149
+ applies to `text`, `html` and `markdown`; `with_blocks=True` is unchanged. The title is
150
+ kept by default.
151
+
125
152
  The larger model, from the `[full]` install:
126
153
 
127
154
  ```python
@@ -161,10 +188,18 @@ Composite image showing the global distribution of photosynthesis, including bot
161
188
 
162
189
  | `output` | what you get |
163
190
  |---|---|
164
- | `text` (default) | selected content as plain text |
191
+ | `text` (default) | selected content as plain text: no heading, emphasis, code, quote, rule or fence markers; list bullets, ordered-list numbers and table rows (`\| a \| b \|`) stay |
165
192
  | `html` | selected DOM, with structure / links / images kept |
166
193
  | `markdown` | clean markdown, links and images preserved |
167
194
 
195
+ The lines exactly as the model scored them, markdown markers included, come from
196
+ `with_blocks=True`:
197
+
198
+ ```python
199
+ blocks, probs = ex.extract(html, with_blocks=True)
200
+ scored = "\n".join(b for b, p in zip(blocks, probs) if p > 0.5)
201
+ ```
202
+
168
203
 
169
204
  ## Command line
170
205
 
@@ -174,6 +209,7 @@ htmlsift extract page.html --output markdown --base-url https://example.com/page
174
209
  curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
175
210
  htmlsift extract "crawl/*.html" > out.jsonl # one JSON record per file
176
211
  htmlsift extract "crawl/**/*.html" --mode base --device cuda > out.jsonl
212
+ htmlsift --version # htmlsift, Python and backend versions
177
213
  ```
178
214
 
179
215
  One file or `-` (stdin) prints the content itself. Several files or a glob print one JSON
@@ -189,6 +225,7 @@ work the same in every shell; directories are not walked.
189
225
  | `--mode mini\|base`, `--device` | model and torch device, as for `Extractor` |
190
226
  | `--base-url URL` | make links absolute; applies to every input |
191
227
  | `--encoding ENC` | charset of the input bytes; applies to every input |
228
+ | `--drop-title` | leave out the leading page title, as `drop_title=True` |
192
229
  | `--jsonl` | always print JSON records |
193
230
 
194
231
  Files are read as bytes, so the charset is detected as described above. Output is utf-8.
@@ -50,6 +50,12 @@ Requires Python 3.10+. Model files download from the Hugging Face Hub on first u
50
50
  cached; run `htmlsift download <mode>` to prefetch, or set `HF_HUB_OFFLINE=1` to run from
51
51
  cache only.
52
52
 
53
+ ### Memory (`mini`)
54
+
55
+ ~340 MB once loaded; 99 % of CommonCrawl pages need under 50 MB more. Memory grows with
56
+ page length (~21 KB per rendered line), so a very large page can exhaust RAM on a 1–2 GB
57
+ machine. Cap the input size before calling `extract()`.
58
+
53
59
  ## Usage
54
60
 
55
61
  ```python
@@ -80,6 +86,14 @@ is honoured):
80
86
  ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
81
87
  ```
82
88
 
89
+ Leave out the page title that starts the content, as pretraining text pipelines usually do
90
+ (the first line, when it is an `h1`–`h6` or `p` matching the page's `<title>` or
91
+ `og:title`; kept by default):
92
+
93
+ ```python
94
+ ex.extract(html, drop_title=True)
95
+ ```
96
+
83
97
  The larger model, from `htmlsift[full]`:
84
98
 
85
99
  ```python
@@ -93,19 +107,29 @@ ex = Extractor("base", device="cuda") # require the GPU
93
107
 
94
108
  | `output` | what you get |
95
109
  |---|---|
96
- | `text` (default) | selected content as plain text |
110
+ | `text` (default) | selected content as plain text: no heading, emphasis, code, quote, rule or fence markers; list bullets, ordered-list numbers and table rows (`\| a \| b \|`) stay |
97
111
  | `html` | selected DOM, with structure / links / images kept |
98
112
  | `markdown` | clean markdown, links and images preserved |
99
113
 
114
+ The lines exactly as the model scored them, markdown markers included, come from
115
+ `with_blocks=True`:
116
+
117
+ ```python
118
+ blocks, probs = ex.extract(html, with_blocks=True)
119
+ scored = "\n".join(b for b, p in zip(blocks, probs) if p > 0.5)
120
+ ```
121
+
100
122
  ## Command line
101
123
 
102
124
  ```bash
103
125
  htmlsift extract page.html # main content of one page
104
126
  curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
105
127
  htmlsift extract "crawl/*.html" --output markdown > out.jsonl # one JSON line per file
128
+ htmlsift --version # htmlsift, Python and backend versions
106
129
  ```
107
130
 
108
- Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--jsonl` (records
131
+ Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--drop-title`,
132
+ `--jsonl` (records
109
133
  even for a single file). Failed files become `{"path", "error"}` records and the exit code
110
134
  is 1. Output is utf-8.
111
135
 
@@ -4,6 +4,6 @@
4
4
  """
5
5
  from .extractor import Extractor, extract
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.2.1"
8
8
 
9
9
  __all__ = ["Extractor", "extract"]
@@ -0,0 +1,63 @@
1
+ """Leading page-title detection for drop_title: the first kept line, when it comes from a
2
+ heading or paragraph that matches the page's <title> or og:title."""
3
+ import difflib
4
+ import re
5
+ import unicodedata
6
+
7
+ TITLE_SEP = re.compile(r"\s[|\-–—:·•»/~]{1,2}\s|\s*[|·•»]\s*")
8
+ SIM = 0.8
9
+ _TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6", "p"})
10
+
11
+
12
+ def norm(s):
13
+ """NFKC, casefold, runs of non-alphanumerics to one space."""
14
+ s = unicodedata.normalize("NFKC", s).casefold()
15
+ return re.sub(r"[\W_]+", " ", s).strip()
16
+
17
+
18
+ def title_candidates(title, og):
19
+ """Normalised whole titles and their segments split at TITLE_SEP."""
20
+ out = set()
21
+ for t in (title, og):
22
+ if t:
23
+ out.add(norm(t))
24
+ out.update(norm(p) for p in TITLE_SEP.split(t))
25
+ out.discard("")
26
+ return out
27
+
28
+
29
+ def title_match(line, title, og):
30
+ """Best SequenceMatcher ratio between the normalised line and any title candidate."""
31
+ h = norm(line)
32
+ return max((difflib.SequenceMatcher(None, h, c, autojunk=False).ratio()
33
+ for c in title_candidates(title, og)), default=0.0)
34
+
35
+
36
+ def page_titles(root):
37
+ """(first <title>, og:title), whitespace collapsed; None where absent."""
38
+ t = root.xpath("//title")
39
+ og = root.xpath("//meta[@property='og:title' or @name='og:title']/@content")
40
+ title = " ".join(t[0].text_content().split()) if t else None
41
+ return title, (" ".join(og[0].split()) if og else None)
42
+
43
+
44
+ def _enclosing(el):
45
+ """Nearest ancestor-or-self that is h1-h6 or p, else None."""
46
+ while el is not None:
47
+ if isinstance(el.tag, str) and el.tag.lower() in _TAGS:
48
+ return el
49
+ el = el.getparent()
50
+ return None
51
+
52
+
53
+ def find_title(root, k, line, block_src):
54
+ """The h1-h6/p element behind block k (text: line) if it is the page title, else None.
55
+ All of block k's elements must sit in that one element, the element's text must be
56
+ this line alone, and line must match <title> or og:title."""
57
+ encl = {_enclosing(e) for e in block_src[k]}
58
+ if len(encl) != 1 or None in encl:
59
+ return None
60
+ el = encl.pop()
61
+ if norm(el.text_content()) != norm(line):
62
+ return None
63
+ return el if title_match(line, *page_titles(root)) >= SIM else None
@@ -10,13 +10,45 @@ import errno
10
10
  import glob
11
11
  import json
12
12
  import os
13
+ import platform
13
14
  import sys
15
+ from importlib.metadata import PackageNotFoundError, version
14
16
  from pathlib import Path
15
17
 
16
18
  from w3lib.encoding import resolve_encoding
17
19
 
20
+ from . import __version__
18
21
  from ._artifacts import BUNDLE_FILES, resolve_bundle
19
22
  from .extractor import Extractor
23
+ from .loader import BASE_DEPS, FULL_INSTALL
24
+
25
+
26
+ def _dist_version(name):
27
+ try:
28
+ return version(name)
29
+ except PackageNotFoundError:
30
+ return None
31
+
32
+
33
+ def _version_text():
34
+ """htmlsift, Python and backend versions, read from package metadata (no imports)."""
35
+ def backend(mode, deps):
36
+ found = {d: _dist_version(d) for d in deps}
37
+ line = f"{mode}: " + ", ".join(f"{d} {v or 'not installed'}" for d, v in found.items())
38
+ return line if all(found.values()) else f"{line} ({FULL_INSTALL})"
39
+ return "\n".join([f"htmlsift {__version__}",
40
+ f"python {platform.python_version()}",
41
+ backend("mini", ["onnxruntime"]),
42
+ backend("base", BASE_DEPS)]) + "\n"
43
+
44
+
45
+ class _Version(argparse.Action):
46
+ def __init__(self, option_strings, dest, **kwargs):
47
+ super().__init__(option_strings, dest, nargs=0, help="print versions and exit")
48
+
49
+ def __call__(self, parser, namespace, values, option_string=None):
50
+ sys.stdout.write(_version_text())
51
+ parser.exit()
20
52
 
21
53
 
22
54
  def _download(mode):
@@ -92,7 +124,8 @@ def _extract(ap, args):
92
124
  if error is None:
93
125
  try:
94
126
  content = ex.extract(_read(path), output=args.output,
95
- encoding=args.encoding, base_url=args.base_url)
127
+ encoding=args.encoding, base_url=args.base_url,
128
+ drop_title=args.drop_title)
96
129
  except Exception as e:
97
130
  error = str(e) or type(e).__name__
98
131
  failed = failed or error is not None
@@ -105,6 +138,7 @@ def _extract(ap, args):
105
138
 
106
139
  def main(argv=None):
107
140
  ap = argparse.ArgumentParser(prog="htmlsift")
141
+ ap.add_argument("--version", action=_Version)
108
142
  sub = ap.add_subparsers(dest="cmd", required=True)
109
143
 
110
144
  dl = sub.add_parser("download", help="prefetch a model bundle into the HF cache")
@@ -122,6 +156,9 @@ def main(argv=None):
122
156
  ex.add_argument("--base-url", help="make links absolute (html/markdown); applies to "
123
157
  "every input")
124
158
  ex.add_argument("--encoding", help="charset of the input bytes; applies to every input")
159
+ ex.add_argument("--drop-title", action="store_true",
160
+ help="leave out a leading line that matches the page's <title> or "
161
+ "og:title")
125
162
  ex.add_argument("--jsonl", action="store_true",
126
163
  help="always print JSON records, even for a single input")
127
164
 
@@ -0,0 +1,55 @@
1
+ """Torch-free onnxruntime forward for the mini model (int8 table + BiGRU head).
2
+
3
+ Embed the page's distinct token ids through the int8 table graph, mean-pool by block
4
+ membership, concat any structural features, run the head graph, sigmoid. numpy +
5
+ onnxruntime only -- no torch. The table is a per-id lookup, so embedding each distinct id
6
+ once gives the same rows as embedding every token, and memory scales with the page's
7
+ vocabulary instead of its length.
8
+ """
9
+ import numpy as np
10
+ import onnxruntime as ort
11
+
12
+ # onnxruntime's arena keeps its peak after a run. Pages estimated above SHRINK_MB give
13
+ # the memory back when they finish; smaller pages keep the arena for speed.
14
+ TABLE_KB_PER_ID = 12
15
+ HEAD_KB_PER_BLOCK = 21
16
+ SHRINK_MB = 100
17
+
18
+
19
+ def arena_mb(n_unique, n_blocks):
20
+ """Estimated arena memory (MB) one page needs: table per distinct id + head per block."""
21
+ return (TABLE_KB_PER_ID * n_unique + HEAD_KB_PER_BLOCK * n_blocks) / 1024
22
+
23
+
24
+ def pool(emb_u, inv, members):
25
+ """Per-block mean of the member tokens' embeddings -> [n, H]. emb_u holds one row
26
+ per distinct id and emb_u[inv[t]] is token t's. A token-less block pools to zeros."""
27
+ pooled = np.zeros((len(members), emb_u.shape[1]), dtype=np.float32)
28
+ for i, mem in enumerate(members):
29
+ if mem:
30
+ pooled[i] = emb_u[inv[mem]].mean(0)
31
+ return pooled
32
+
33
+
34
+ def load_mini(table_path, head_path, threads=None):
35
+ """The two mini graphs -> infer_fn(page) -> per-block probs (np.float32): the
36
+ (page -> probs) contract the extractor consumes."""
37
+ opts = ort.SessionOptions()
38
+ if threads:
39
+ opts.intra_op_num_threads, opts.inter_op_num_threads = threads, 1
40
+ table = ort.InferenceSession(str(table_path), opts, providers=["CPUExecutionProvider"])
41
+ head = ort.InferenceSession(str(head_path), opts, providers=["CPUExecutionProvider"])
42
+ shrink = ort.RunOptions()
43
+ shrink.add_run_config_entry("memory.enable_memory_arena_shrinkage", "cpu:0")
44
+
45
+ def infer_fn(page):
46
+ uniq, inv = np.unique(page["ids"], return_inverse=True)
47
+ members = page["members"]
48
+ ro = shrink if arena_mb(len(uniq), len(members)) > SHRINK_MB else None
49
+ emb_u = table.run(["out"], {"ids": uniq.astype(np.int64)}, ro)[0] # [U, H]
50
+ x = pool(emb_u, inv, members) # [n, H]
51
+ if page.get("feats") is not None:
52
+ x = np.concatenate([x, page["feats"].astype(np.float32)], 1) # [n, H+K]
53
+ logits = head.run(["logits"], {"x": x[None]}, ro)[0][0] # [n]
54
+ return (1.0 / (1.0 + np.exp(-logits))).astype(np.float32)
55
+ return infer_fn
@@ -100,6 +100,9 @@ def _cell_text(el):
100
100
  class _Renderer:
101
101
  """Accumulates output lines and, per line, the set of source elements."""
102
102
 
103
+ HR = "* * *"
104
+ FENCE = "```"
105
+
103
106
  def __init__(self, render_hidden=False, links=False):
104
107
  self.render_hidden = render_hidden
105
108
  self.links = links
@@ -117,6 +120,15 @@ class _Renderer:
117
120
  def _qpfx(self):
118
121
  return "> " * self.quote
119
122
 
123
+ def _heading_pfx(self, level):
124
+ return "#" * level + " "
125
+
126
+ def _cell(self, txt):
127
+ return txt
128
+
129
+ def _wrap(self, el, open_s, inner, close_s):
130
+ return open_s + inner + close_s
131
+
120
132
  def _append(self, line, src):
121
133
  self.swallow = False
122
134
  if self.pending is not None:
@@ -180,10 +192,10 @@ class _Renderer:
180
192
  elif tag == "br":
181
193
  self.flush()
182
194
  elif tag == "hr":
183
- self.blank(); self.emit("* * *", {el}); self.blank()
195
+ self.blank(); self.emit(self.HR, {el}); self.blank()
184
196
  elif tag in HEADING_TAGS:
185
197
  self.blank()
186
- self.linepfx = "#" * HEADING_TAGS[tag] + " "
198
+ self.linepfx = self._heading_pfx(HEADING_TAGS[tag])
187
199
  self.inner_keeping_lead(el)
188
200
  self.blank()
189
201
  elif tag in LIST_TAGS:
@@ -210,7 +222,7 @@ class _Renderer:
210
222
  open_s, close_s = WRAP_TAGS[tag]
211
223
  inner = _sub_inline(el, self.render_hidden, self.links)
212
224
  if inner:
213
- self.raw(open_s + inner + close_s, el)
225
+ self.raw(self._wrap(el, open_s, inner, close_s), el)
214
226
  else:
215
227
  self.swallow = True
216
228
  elif tag == "a" and self.links and el.get("href"):
@@ -256,10 +268,10 @@ class _Renderer:
256
268
 
257
269
  def pre(self, el):
258
270
  self.blank()
259
- self._append("```", {el})
271
+ self._append(self.FENCE, {el})
260
272
  for line in el.text_content().split("\n"):
261
273
  self._append(line, {el})
262
- self._append("```", {el})
274
+ self._append(self.FENCE, {el})
263
275
  self.blank()
264
276
 
265
277
  def table(self, el):
@@ -288,7 +300,7 @@ class _Renderer:
288
300
  texts, src = [], {tr}
289
301
  for c in cells:
290
302
  t, s = _cell_text(c)
291
- texts.append(t); src |= s
303
+ texts.append(self._cell(t)); src |= s
292
304
  if not any(texts): # skip fully-empty rows
293
305
  continue
294
306
  self.emit("| " + " | ".join(texts) + " |", src)
@@ -298,12 +310,40 @@ class _Renderer:
298
310
  self.blank()
299
311
 
300
312
 
301
- def _sub_inline(el, render_hidden=False, links=False):
302
- sub = _Renderer(render_hidden, links); sub.children(el); sub.flush()
313
+ class _PlainRenderer(_Renderer):
314
+ """The same walk and the same lines as _Renderer, without markdown markers.
315
+ Output only; list bullets, ordered numbers and table pipes stay."""
316
+
317
+ HR = ""
318
+ FENCE = ""
319
+
320
+ def _qpfx(self):
321
+ return ""
322
+
323
+ def _heading_pfx(self, level):
324
+ return ""
325
+
326
+ def _cell(self, txt):
327
+ return txt.replace("\\|", "|")
328
+
329
+ def _wrap(self, el, open_s, inner, close_s):
330
+ # The markdown inner decided that a chunk exists; this one may be "" (<b><hr></b>).
331
+ return _sub_inline(el, self.render_hidden, self.links, _PlainRenderer)
332
+
333
+ def text(self, s, src):
334
+ lead = self.keep_lead and not self.linepfx and not any(self.buf)
335
+ super().text(s, src)
336
+ if lead and self.buf:
337
+ # Heading lead space; the chunk stays in buf so the line is still emitted.
338
+ self.buf[-1] = self.buf[-1].lstrip()
339
+
340
+
341
+ def _sub_inline(el, render_hidden=False, links=False, cls=_Renderer):
342
+ sub = cls(render_hidden, links); sub.children(el); sub.flush()
303
343
  return " ".join(ln.strip() for ln in sub.lines if ln.strip())
304
344
 
305
345
 
306
- def render_tree(root, render_hidden=False, links=False):
346
+ def render_tree(root, render_hidden=False, links=False, plain=False):
307
347
  """Walk a parsed tree -> (lines, line_src). line_src[i] is the set of source
308
348
  DOM elements that produced line i (empty for structural separators).
309
349
 
@@ -314,15 +354,19 @@ def render_tree(root, render_hidden=False, links=False):
314
354
 
315
355
  links=True renders <a>/<img> as markdown ([text](href), ![alt](src)) -- an
316
356
  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()
357
+ training still matches; only serialization sets links=True.
358
+
359
+ plain=True renders the same lines without markdown markers (headings, emphasis,
360
+ code, quotes, rules, fences) -- output-only, like links."""
361
+ cls = _PlainRenderer if plain else _Renderer
362
+ r = cls(render_hidden, links); r.walk(root); r.flush()
319
363
  return r.lines, r.line_src
320
364
 
321
365
 
322
- def render(html_str, render_hidden=False, links=False):
366
+ def render(html_str, render_hidden=False, links=False, plain=False):
323
367
  """HTML string -> readable markdown text (inference; provenance discarded).
324
368
  Does not sanitize — a caller that needs it sanitizes the tree first."""
325
369
  if not html_str or not html_str.strip():
326
370
  return ""
327
- lines, _ = render_tree(parse(html_str), render_hidden, links)
371
+ lines, _ = render_tree(parse(html_str), render_hidden, links, plain)
328
372
  return "\n".join(lines) + "\n"