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.
- htmlsift-0.2.1/.github/workflows/ci.yml +50 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/CHANGELOG.md +40 -1
- {htmlsift-0.2.0 → htmlsift-0.2.1}/PKG-INFO +27 -3
- {htmlsift-0.2.0 → htmlsift-0.2.1}/README.md +39 -2
- {htmlsift-0.2.0 → htmlsift-0.2.1}/README.pypi.md +26 -2
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/__init__.py +1 -1
- htmlsift-0.2.1/src/htmlsift/_title.py +63 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/cli.py +38 -1
- htmlsift-0.2.1/src/htmlsift/core/onnx_infer.py +55 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/render.py +57 -13
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/extractor.py +63 -15
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/loader.py +11 -0
- htmlsift-0.2.1/tests/data/sample.extracted.txt +12 -0
- htmlsift-0.2.1/tests/test_api.py +223 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_cli.py +50 -4
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_decode.py +16 -4
- htmlsift-0.2.1/tests/test_onnx_infer.py +110 -0
- htmlsift-0.2.1/tests/test_render_plain.py +72 -0
- htmlsift-0.2.1/tests/test_title.py +139 -0
- htmlsift-0.2.0/.github/workflows/ci.yml +0 -24
- htmlsift-0.2.0/src/htmlsift/core/onnx_infer.py +0 -38
- htmlsift-0.2.0/tests/data/sample.extracted.txt +0 -15
- htmlsift-0.2.0/tests/test_api.py +0 -64
- {htmlsift-0.2.0 → htmlsift-0.2.1}/.gitattributes +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/.github/workflows/publish.yml +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/.gitignore +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/AGENTS.md +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/LICENSE +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/annotation-example.svg +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/architecture.svg +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/concept.svg +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/cpu_speed_accuracy.svg +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/data-efficiency.svg +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/gpu_speed_accuracy_lite.svg +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/docs/assets/signals.svg +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/pyproject.toml +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/_artifacts.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/_decode.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/_links.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/__init__.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/band.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/features.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/prep_page.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/prune.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/tokenizer.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/core/torch_infer.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/src/htmlsift/py.typed +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/__init__.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/conftest.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/article_fi.html +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/article_ja.html +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.extracted.html +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.extracted.md +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.html +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/data/sample.rendered.txt +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_extract_golden.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_features.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_links.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_manifest.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_prep_page.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_prune.py +0 -0
- {htmlsift-0.2.0 → htmlsift-0.2.1}/tests/test_render_golden.py +0 -0
- {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.
|
|
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.
|
|
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`, `--
|
|
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
|
|
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`, `--
|
|
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
|
|
|
@@ -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(
|
|
195
|
+
self.blank(); self.emit(self.HR, {el}); self.blank()
|
|
184
196
|
elif tag in HEADING_TAGS:
|
|
185
197
|
self.blank()
|
|
186
|
-
self.linepfx =
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
302
|
-
|
|
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), ) -- 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
|
-
|
|
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"
|