htmlsift 0.1.1__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.1/CHANGELOG.md +111 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/PKG-INFO +55 -3
- {htmlsift-0.1.1 → htmlsift-0.2.1}/README.md +102 -3
- {htmlsift-0.1.1 → htmlsift-0.2.1}/README.pypi.md +53 -2
- {htmlsift-0.1.1 → htmlsift-0.2.1}/pyproject.toml +1 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/__init__.py +1 -1
- htmlsift-0.2.1/src/htmlsift/_decode.py +45 -0
- htmlsift-0.2.1/src/htmlsift/_links.py +52 -0
- htmlsift-0.2.1/src/htmlsift/_title.py +63 -0
- htmlsift-0.2.1/src/htmlsift/cli.py +172 -0
- htmlsift-0.2.1/src/htmlsift/core/onnx_infer.py +55 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/prune.py +5 -4
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/render.py +57 -13
- htmlsift-0.2.1/src/htmlsift/extractor.py +153 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/loader.py +11 -0
- htmlsift-0.2.1/tests/conftest.py +16 -0
- htmlsift-0.2.1/tests/data/article_fi.html +32 -0
- htmlsift-0.2.1/tests/data/article_ja.html +29 -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.1/tests/test_cli.py +213 -0
- htmlsift-0.2.1/tests/test_decode.py +165 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_extract_golden.py +29 -0
- htmlsift-0.2.1/tests/test_links.py +163 -0
- htmlsift-0.2.1/tests/test_onnx_infer.py +110 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_prune.py +20 -0
- htmlsift-0.2.1/tests/test_render_plain.py +72 -0
- htmlsift-0.2.1/tests/test_title.py +139 -0
- htmlsift-0.1.1/.github/workflows/ci.yml +0 -24
- htmlsift-0.1.1/CHANGELOG.md +0 -23
- htmlsift-0.1.1/src/htmlsift/cli.py +0 -29
- htmlsift-0.1.1/src/htmlsift/core/onnx_infer.py +0 -38
- htmlsift-0.1.1/src/htmlsift/extractor.py +0 -91
- htmlsift-0.1.1/tests/data/sample.extracted.txt +0 -15
- htmlsift-0.1.1/tests/test_api.py +0 -32
- {htmlsift-0.1.1 → htmlsift-0.2.1}/.gitattributes +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/.github/workflows/publish.yml +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/.gitignore +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/AGENTS.md +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/LICENSE +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/annotation-example.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/architecture.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/concept.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/cpu_speed_accuracy.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/data-efficiency.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/gpu_speed_accuracy_lite.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/signals.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/_artifacts.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/__init__.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/band.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/features.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/prep_page.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/tokenizer.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/torch_infer.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/py.typed +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/__init__.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.extracted.html +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.extracted.md +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.html +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.rendered.txt +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_features.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_manifest.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_prep_page.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_render_golden.py +0 -0
- {htmlsift-0.1.1 → 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
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to htmlsift are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
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
|
+
|
|
47
|
+
## [0.2.0] - 2026-11-02
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
- `extract()` accepts `bytes` and decodes them: byte order mark, then `encoding=`, then the
|
|
51
|
+
page's `<meta>` declaration, then utf-8, falling back to cp1252. Legacy charset labels map
|
|
52
|
+
the way browsers read them (`latin1` → cp1252, `shift_jis` → cp932, `gb2312` → gb18030).
|
|
53
|
+
- Keyword-only `encoding=` on `Extractor.extract` and `extract`, for a charset known from
|
|
54
|
+
an HTTP or WARC header.
|
|
55
|
+
- Dependency: `w3lib` (BSD-3-Clause) for charset sniffing.
|
|
56
|
+
- Keyword-only `base_url=` on `Extractor.extract` and `extract`: links and image URLs in
|
|
57
|
+
`html` and `markdown` output are made absolute against it. The page's first
|
|
58
|
+
`<base href>` is resolved against `base_url`; malformed links are left as they are.
|
|
59
|
+
`text` output and `with_blocks` are unaffected.
|
|
60
|
+
- `htmlsift extract` command: files, quoted globs or stdin in; the content of a single
|
|
61
|
+
input, or one JSON line per file (`--jsonl` to force), out. Flags `--output`,
|
|
62
|
+
`--mode`, `--device`, `--base-url`, `--encoding`. Failed files are reported as
|
|
63
|
+
records and the run exits with 1.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
- Input other than `str` or `bytes` raises `TypeError`; `extract(None)` previously
|
|
67
|
+
returned `""`.
|
|
68
|
+
- `encoding=` with `str` input raises `TypeError`, since a `str` is already decoded.
|
|
69
|
+
- A page with an absolute `<base href>` gets absolute links in `html` and `markdown` output
|
|
70
|
+
even without `base_url`. Previously `<base>` was dropped with `<head>`, leaving its links
|
|
71
|
+
relative.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
- Non-utf-8 `bytes` input crashed with `UnicodeDecodeError`.
|
|
75
|
+
- `html` and `markdown` output could drop images and other inline content inside a kept
|
|
76
|
+
block, varying from run to run on the same page. `text` output was unaffected.
|
|
77
|
+
|
|
78
|
+
## [0.1.1] - 2026-09-23
|
|
79
|
+
|
|
80
|
+
Documentation and packaging metadata only — no code or model changes since 0.1.0.
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
- Project URLs in package metadata (research repo, Hugging Face weights, issue tracker).
|
|
84
|
+
- PyPI Trusted Publishing workflow (`.github/workflows/publish.yml`).
|
|
85
|
+
- Cross-benchmark accuracy table (`base`/`mini` vs trafilatura, readability, resiliparse on
|
|
86
|
+
WMB, WCXB, DAnIEL) and an "Annotation is a policy" section in the README.
|
|
87
|
+
- GPU-on-Windows install note (CUDA torch from the PyTorch index).
|
|
88
|
+
|
|
89
|
+
### Changed
|
|
90
|
+
- Stated the model-weight license (Apache-2.0) and added WebMainBench attribution.
|
|
91
|
+
- Cited the WebMainBench source paper (Liu et al., *Dripper*, arXiv:2511.23119).
|
|
92
|
+
- Clarified model shorthand and tightened copy in the README and the PyPI description.
|
|
93
|
+
|
|
94
|
+
## [0.1.0] - 2026-09-22
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
- Initial release.
|
|
98
|
+
- `mini` model (int8 ONNX, runs on CPU) and `base` model (311M encoder, GPU-capable),
|
|
99
|
+
installed via `pip install htmlsift` and `pip install htmlsift[full]` respectively.
|
|
100
|
+
- `Extractor(mode, *, bundle_dir, threads, device)` with
|
|
101
|
+
`.extract(html, output=..., with_blocks=...)`, plus the module-level `extract(...)`.
|
|
102
|
+
- Output formats: plain text, HTML, and markdown.
|
|
103
|
+
- Device selection for `base`: `device="cuda"` / `"cpu"`, auto-detecting a GPU and
|
|
104
|
+
warning on CPU fallback.
|
|
105
|
+
- Model bundles fetched from the Hugging Face Hub, pinned to a fixed commit revision.
|
|
106
|
+
|
|
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
|
|
109
|
+
[0.2.0]: https://github.com/koivualeksi/htmlsift/compare/v0.1.1...v0.2.0
|
|
110
|
+
[0.1.1]: https://github.com/koivualeksi/htmlsift/compare/v0.1.0...v0.1.1
|
|
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.
|
|
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
|
|
@@ -24,6 +24,7 @@ Requires-Dist: lxml
|
|
|
24
24
|
Requires-Dist: numpy
|
|
25
25
|
Requires-Dist: onnxruntime
|
|
26
26
|
Requires-Dist: tokenizers
|
|
27
|
+
Requires-Dist: w3lib<3,>=2.5
|
|
27
28
|
Provides-Extra: full
|
|
28
29
|
Requires-Dist: torch; extra == 'full'
|
|
29
30
|
Requires-Dist: transformers; extra == 'full'
|
|
@@ -73,7 +74,7 @@ pip install htmlsift[full] # adds the base 311M model (torch; GPU-capable)
|
|
|
73
74
|
```
|
|
74
75
|
|
|
75
76
|
- `htmlsift` — the `mini` model only; few dependencies (onnxruntime + tokenizers + lxml +
|
|
76
|
-
numpy + huggingface-hub). First use downloads ~230 MB of model files.
|
|
77
|
+
numpy + huggingface-hub + w3lib). First use downloads ~230 MB of model files.
|
|
77
78
|
- `htmlsift[full]` — adds `torch` + `transformers` to run `base`. First use of `base`
|
|
78
79
|
downloads ~1 GB.
|
|
79
80
|
|
|
@@ -81,6 +82,12 @@ Requires Python 3.10+. Model files download from the Hugging Face Hub on first u
|
|
|
81
82
|
cached; run `htmlsift download <mode>` to prefetch, or set `HF_HUB_OFFLINE=1` to run from
|
|
82
83
|
cache only.
|
|
83
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
|
+
|
|
84
91
|
## Usage
|
|
85
92
|
|
|
86
93
|
```python
|
|
@@ -96,6 +103,29 @@ import htmlsift
|
|
|
96
103
|
htmlsift.extract(html) # module-level convenience
|
|
97
104
|
```
|
|
98
105
|
|
|
106
|
+
Raw bytes are decoded for you (byte order mark, then `encoding=`, then `<meta charset>`,
|
|
107
|
+
then utf-8 / cp1252):
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
ex.extract(response.content) # charset from the page itself
|
|
111
|
+
ex.extract(payload, encoding="windows-1251") # charset from the HTTP / WARC header
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Make links and image URLs absolute in `html` / `markdown` output (the page's `<base href>`
|
|
115
|
+
is honoured):
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
|
|
119
|
+
```
|
|
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
|
+
|
|
99
129
|
The larger model, from `htmlsift[full]`:
|
|
100
130
|
|
|
101
131
|
```python
|
|
@@ -109,10 +139,32 @@ ex = Extractor("base", device="cuda") # require the GPU
|
|
|
109
139
|
|
|
110
140
|
| `output` | what you get |
|
|
111
141
|
|---|---|
|
|
112
|
-
| `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 |
|
|
113
143
|
| `html` | selected DOM, with structure / links / images kept |
|
|
114
144
|
| `markdown` | clean markdown, links and images preserved |
|
|
115
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
|
+
|
|
154
|
+
## Command line
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
htmlsift extract page.html # main content of one page
|
|
158
|
+
curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
|
|
159
|
+
htmlsift extract "crawl/*.html" --output markdown > out.jsonl # one JSON line per file
|
|
160
|
+
htmlsift --version # htmlsift, Python and backend versions
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--drop-title`,
|
|
164
|
+
`--jsonl` (records
|
|
165
|
+
even for a single file). Failed files become `{"path", "error"}` records and the exit code
|
|
166
|
+
is 1. Output is utf-8.
|
|
167
|
+
|
|
116
168
|
## License
|
|
117
169
|
|
|
118
170
|
Apache-2.0. Model weights are distributed from the Hugging Face Hub, also under Apache-2.0, and
|
|
@@ -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:
|
|
@@ -65,7 +65,7 @@ pip install htmlsift[full] # adds the base 311M model (torch; GPU-capable)
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
- **`htmlsift`** — the `mini` model only; few dependencies (onnxruntime + tokenizers + lxml +
|
|
68
|
-
numpy + huggingface-hub). First use downloads ~230 MB of model files.
|
|
68
|
+
numpy + huggingface-hub + w3lib). First use downloads ~230 MB of model files.
|
|
69
69
|
- **`htmlsift[full]`** — adds `torch` + `transformers` to run `base`. First use of `base`
|
|
70
70
|
downloads ~1 GB.
|
|
71
71
|
|
|
@@ -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
|
|
@@ -92,6 +103,52 @@ import htmlsift
|
|
|
92
103
|
htmlsift.extract(html) # default mini
|
|
93
104
|
```
|
|
94
105
|
|
|
106
|
+
### Bytes and encodings
|
|
107
|
+
|
|
108
|
+
Pass raw bytes from a file, an HTTP response or a WARC record, and htmlsift decodes them:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
ex.extract(response.content) # charset from the page itself
|
|
112
|
+
ex.extract(payload, encoding="windows-1251") # charset from the HTTP / WARC header
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The charset is taken from, in order: a byte order mark, `encoding=`, the page's
|
|
116
|
+
`<meta charset>`; an undeclared page is read as utf-8, falling back to cp1252. Unknown
|
|
117
|
+
labels are skipped and undecodable bytes become `�`. A `str` counts as already decoded, so
|
|
118
|
+
`encoding=` with a `str` raises `TypeError`. Prefer passing bytes over a `str` decoded
|
|
119
|
+
upstream: `requests`' `response.text`, for one, guesses ISO-8859-1 for pages without a
|
|
120
|
+
charset header.
|
|
121
|
+
|
|
122
|
+
### Absolute links
|
|
123
|
+
|
|
124
|
+
Pass the page's URL to make links and image URLs absolute in `html` and `markdown` output:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
|
|
128
|
+
# [the report](report) -> [the report](https://example.com/blog/report)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The page's first `<base href>` is resolved against `base_url`, as a browser does. Without
|
|
132
|
+
`base_url`, an absolute `<base href>` is used on its own; with neither, links stay as they
|
|
133
|
+
are. Malformed links are left unchanged. `text` output has no links, so it ignores
|
|
134
|
+
`base_url`.
|
|
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
|
+
|
|
95
152
|
The larger model, from the `[full]` install:
|
|
96
153
|
|
|
97
154
|
```python
|
|
@@ -131,10 +188,49 @@ Composite image showing the global distribution of photosynthesis, including bot
|
|
|
131
188
|
|
|
132
189
|
| `output` | what you get |
|
|
133
190
|
|---|---|
|
|
134
|
-
| `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 |
|
|
135
192
|
| `html` | selected DOM, with structure / links / images kept |
|
|
136
193
|
| `markdown` | clean markdown, links and images preserved |
|
|
137
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
|
+
|
|
203
|
+
|
|
204
|
+
## Command line
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
htmlsift extract page.html # main content of one page
|
|
208
|
+
htmlsift extract page.html --output markdown --base-url https://example.com/page
|
|
209
|
+
curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
|
|
210
|
+
htmlsift extract "crawl/*.html" > out.jsonl # one JSON record per file
|
|
211
|
+
htmlsift extract "crawl/**/*.html" --mode base --device cuda > out.jsonl
|
|
212
|
+
htmlsift --version # htmlsift, Python and backend versions
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
One file or `-` (stdin) prints the content itself. Several files or a glob print one JSON
|
|
216
|
+
line per file, `{"path": ..., "content": ...}`, or `{"path": ..., "error": ...}` for a file
|
|
217
|
+
that failed; the run continues and exits with 1 at the end. Pass `--jsonl` to get records
|
|
218
|
+
for a single file too, which scripts should do: bash expands an unquoted `*.html` before
|
|
219
|
+
htmlsift sees it. Quoted globs are expanded by htmlsift itself (`**` recurses), so they
|
|
220
|
+
work the same in every shell; directories are not walked.
|
|
221
|
+
|
|
222
|
+
| flag | |
|
|
223
|
+
|---|---|
|
|
224
|
+
| `--output text\|html\|markdown` | as `output=` above (default `text`) |
|
|
225
|
+
| `--mode mini\|base`, `--device` | model and torch device, as for `Extractor` |
|
|
226
|
+
| `--base-url URL` | make links absolute; applies to every input |
|
|
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` |
|
|
229
|
+
| `--jsonl` | always print JSON records |
|
|
230
|
+
|
|
231
|
+
Files are read as bytes, so the charset is detected as described above. Output is utf-8.
|
|
232
|
+
Exit codes: 0 ok, 1 a file or the model failed, 2 a usage error. In Windows PowerShell 5.1,
|
|
233
|
+
`>` re-encodes a program's output; redirect from bash, cmd or PowerShell 7 instead.
|
|
138
234
|
|
|
139
235
|
## Concept
|
|
140
236
|
|
|
@@ -201,6 +297,9 @@ and its `docs/REPRODUCE.md`.
|
|
|
201
297
|
A standalone hero image that is its own block renders to no text line, so it can't be selected
|
|
202
298
|
today. Inline images inside a kept block are preserved in `html` and `markdown`.
|
|
203
299
|
|
|
300
|
+
`base_url` does not rewrite `srcset`, so responsive images in `html` output keep relative
|
|
301
|
+
candidates (`src` is rewritten; `markdown` uses only `src`).
|
|
302
|
+
|
|
204
303
|
</details>
|
|
205
304
|
|
|
206
305
|
## License
|
|
@@ -42,7 +42,7 @@ pip install htmlsift[full] # adds the base 311M model (torch; GPU-capable)
|
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
- `htmlsift` — the `mini` model only; few dependencies (onnxruntime + tokenizers + lxml +
|
|
45
|
-
numpy + huggingface-hub). First use downloads ~230 MB of model files.
|
|
45
|
+
numpy + huggingface-hub + w3lib). First use downloads ~230 MB of model files.
|
|
46
46
|
- `htmlsift[full]` — adds `torch` + `transformers` to run `base`. First use of `base`
|
|
47
47
|
downloads ~1 GB.
|
|
48
48
|
|
|
@@ -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
|
|
@@ -65,6 +71,29 @@ import htmlsift
|
|
|
65
71
|
htmlsift.extract(html) # module-level convenience
|
|
66
72
|
```
|
|
67
73
|
|
|
74
|
+
Raw bytes are decoded for you (byte order mark, then `encoding=`, then `<meta charset>`,
|
|
75
|
+
then utf-8 / cp1252):
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
ex.extract(response.content) # charset from the page itself
|
|
79
|
+
ex.extract(payload, encoding="windows-1251") # charset from the HTTP / WARC header
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Make links and image URLs absolute in `html` / `markdown` output (the page's `<base href>`
|
|
83
|
+
is honoured):
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
|
|
87
|
+
```
|
|
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
|
+
|
|
68
97
|
The larger model, from `htmlsift[full]`:
|
|
69
98
|
|
|
70
99
|
```python
|
|
@@ -78,10 +107,32 @@ ex = Extractor("base", device="cuda") # require the GPU
|
|
|
78
107
|
|
|
79
108
|
| `output` | what you get |
|
|
80
109
|
|---|---|
|
|
81
|
-
| `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 |
|
|
82
111
|
| `html` | selected DOM, with structure / links / images kept |
|
|
83
112
|
| `markdown` | clean markdown, links and images preserved |
|
|
84
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
|
+
|
|
122
|
+
## Command line
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
htmlsift extract page.html # main content of one page
|
|
126
|
+
curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
|
|
127
|
+
htmlsift extract "crawl/*.html" --output markdown > out.jsonl # one JSON line per file
|
|
128
|
+
htmlsift --version # htmlsift, Python and backend versions
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--drop-title`,
|
|
132
|
+
`--jsonl` (records
|
|
133
|
+
even for a single file). Failed files become `{"path", "error"}` records and the exit code
|
|
134
|
+
is 1. Output is utf-8.
|
|
135
|
+
|
|
85
136
|
## License
|
|
86
137
|
|
|
87
138
|
Apache-2.0. Model weights are distributed from the Hugging Face Hub, also under Apache-2.0, and
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Decode HTML bytes to str.
|
|
2
|
+
|
|
3
|
+
Resolution order: BOM, encoding= argument, <meta> declaration, strict utf-8, cp1252.
|
|
4
|
+
Undecodable bytes become U+FFFD.
|
|
5
|
+
"""
|
|
6
|
+
from w3lib.encoding import html_body_declared_encoding, read_bom, resolve_encoding, to_unicode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _utf8_or_cp1252(data):
|
|
10
|
+
try:
|
|
11
|
+
data.decode("utf-8")
|
|
12
|
+
except UnicodeDecodeError:
|
|
13
|
+
return "cp1252"
|
|
14
|
+
return "utf-8"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def decode_html(data, encoding=None):
|
|
18
|
+
"""Decode HTML bytes to str. encoding= is a charset label, e.g. from an HTTP or WARC
|
|
19
|
+
Content-Type header. Raises TypeError for non-bytes input."""
|
|
20
|
+
if not isinstance(data, bytes):
|
|
21
|
+
raise TypeError(f"decode_html expects bytes, got {type(data).__name__}")
|
|
22
|
+
bom_enc, bom = read_bom(data)
|
|
23
|
+
if bom_enc:
|
|
24
|
+
return to_unicode(data[len(bom):], bom_enc)
|
|
25
|
+
enc = resolve_encoding(encoding.strip()) if encoding else None
|
|
26
|
+
if enc is None:
|
|
27
|
+
enc = html_body_declared_encoding(data)
|
|
28
|
+
# A <meta> readable as ASCII cannot truthfully declare utf-16/32.
|
|
29
|
+
if enc and enc.startswith(("utf-16", "utf-32")):
|
|
30
|
+
enc = "utf-8"
|
|
31
|
+
if enc is None:
|
|
32
|
+
enc = _utf8_or_cp1252(data)
|
|
33
|
+
return to_unicode(data, enc)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def prepare_input(html, encoding=None):
|
|
37
|
+
"""Extractor input -> what parse() takes: str unchanged, bytes decoded and re-encoded
|
|
38
|
+
as utf-8. Raises TypeError for other types, or for encoding= with str input."""
|
|
39
|
+
if isinstance(html, str):
|
|
40
|
+
if encoding is not None:
|
|
41
|
+
raise TypeError("encoding= applies to bytes input; str is already decoded")
|
|
42
|
+
return html
|
|
43
|
+
if isinstance(html, bytes):
|
|
44
|
+
return decode_html(html, encoding).encode("utf-8")
|
|
45
|
+
raise TypeError(f"html must be str or bytes, got {type(html).__name__}")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Make a page's links absolute: resolve the base URL, then rewrite against it."""
|
|
2
|
+
from urllib.parse import urljoin, urlsplit
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _is_absolute_http(url):
|
|
6
|
+
try:
|
|
7
|
+
parts = urlsplit(url)
|
|
8
|
+
except ValueError:
|
|
9
|
+
return False
|
|
10
|
+
return parts.scheme in ("http", "https") and bool(parts.netloc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _first_base_href(root):
|
|
14
|
+
for el in root.iter("base"):
|
|
15
|
+
href = (el.get("href") or "").strip()
|
|
16
|
+
if href:
|
|
17
|
+
return href
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def effective_base(root, base_url=None):
|
|
22
|
+
"""The URL to resolve links against, or None to leave them unchanged.
|
|
23
|
+
|
|
24
|
+
The first non-empty <base href> is resolved against base_url; a malformed or
|
|
25
|
+
non-http(s) result falls back to base_url. Without base_url, the <base href> is
|
|
26
|
+
used only if it is an absolute http(s) URL. A malformed base_url gives None."""
|
|
27
|
+
href = _first_base_href(root)
|
|
28
|
+
if base_url:
|
|
29
|
+
try:
|
|
30
|
+
urlsplit(base_url)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
if href is None:
|
|
34
|
+
return base_url
|
|
35
|
+
try:
|
|
36
|
+
joined = urljoin(base_url, href)
|
|
37
|
+
except ValueError:
|
|
38
|
+
return base_url
|
|
39
|
+
return joined if _is_absolute_http(joined) else base_url
|
|
40
|
+
return href if href is not None and _is_absolute_http(href) else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_absolute(root, base):
|
|
44
|
+
"""Rewrite root's links against base in place; malformed links are left as is.
|
|
45
|
+
<base> tags in root are ignored; srcset is not rewritten."""
|
|
46
|
+
def absolute(link):
|
|
47
|
+
try:
|
|
48
|
+
return urljoin(base, link.strip())
|
|
49
|
+
except ValueError:
|
|
50
|
+
return link
|
|
51
|
+
|
|
52
|
+
root.rewrite_links(absolute, resolve_base_href=False)
|
|
@@ -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
|