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.
Files changed (66) hide show
  1. htmlsift-0.2.1/.github/workflows/ci.yml +50 -0
  2. htmlsift-0.2.1/CHANGELOG.md +111 -0
  3. {htmlsift-0.1.1 → htmlsift-0.2.1}/PKG-INFO +55 -3
  4. {htmlsift-0.1.1 → htmlsift-0.2.1}/README.md +102 -3
  5. {htmlsift-0.1.1 → htmlsift-0.2.1}/README.pypi.md +53 -2
  6. {htmlsift-0.1.1 → htmlsift-0.2.1}/pyproject.toml +1 -0
  7. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/__init__.py +1 -1
  8. htmlsift-0.2.1/src/htmlsift/_decode.py +45 -0
  9. htmlsift-0.2.1/src/htmlsift/_links.py +52 -0
  10. htmlsift-0.2.1/src/htmlsift/_title.py +63 -0
  11. htmlsift-0.2.1/src/htmlsift/cli.py +172 -0
  12. htmlsift-0.2.1/src/htmlsift/core/onnx_infer.py +55 -0
  13. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/prune.py +5 -4
  14. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/render.py +57 -13
  15. htmlsift-0.2.1/src/htmlsift/extractor.py +153 -0
  16. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/loader.py +11 -0
  17. htmlsift-0.2.1/tests/conftest.py +16 -0
  18. htmlsift-0.2.1/tests/data/article_fi.html +32 -0
  19. htmlsift-0.2.1/tests/data/article_ja.html +29 -0
  20. htmlsift-0.2.1/tests/data/sample.extracted.txt +12 -0
  21. htmlsift-0.2.1/tests/test_api.py +223 -0
  22. htmlsift-0.2.1/tests/test_cli.py +213 -0
  23. htmlsift-0.2.1/tests/test_decode.py +165 -0
  24. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_extract_golden.py +29 -0
  25. htmlsift-0.2.1/tests/test_links.py +163 -0
  26. htmlsift-0.2.1/tests/test_onnx_infer.py +110 -0
  27. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_prune.py +20 -0
  28. htmlsift-0.2.1/tests/test_render_plain.py +72 -0
  29. htmlsift-0.2.1/tests/test_title.py +139 -0
  30. htmlsift-0.1.1/.github/workflows/ci.yml +0 -24
  31. htmlsift-0.1.1/CHANGELOG.md +0 -23
  32. htmlsift-0.1.1/src/htmlsift/cli.py +0 -29
  33. htmlsift-0.1.1/src/htmlsift/core/onnx_infer.py +0 -38
  34. htmlsift-0.1.1/src/htmlsift/extractor.py +0 -91
  35. htmlsift-0.1.1/tests/data/sample.extracted.txt +0 -15
  36. htmlsift-0.1.1/tests/test_api.py +0 -32
  37. {htmlsift-0.1.1 → htmlsift-0.2.1}/.gitattributes +0 -0
  38. {htmlsift-0.1.1 → htmlsift-0.2.1}/.github/workflows/publish.yml +0 -0
  39. {htmlsift-0.1.1 → htmlsift-0.2.1}/.gitignore +0 -0
  40. {htmlsift-0.1.1 → htmlsift-0.2.1}/AGENTS.md +0 -0
  41. {htmlsift-0.1.1 → htmlsift-0.2.1}/LICENSE +0 -0
  42. {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/annotation-example.svg +0 -0
  43. {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/architecture.svg +0 -0
  44. {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/concept.svg +0 -0
  45. {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/cpu_speed_accuracy.svg +0 -0
  46. {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/data-efficiency.svg +0 -0
  47. {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/gpu_speed_accuracy_lite.svg +0 -0
  48. {htmlsift-0.1.1 → htmlsift-0.2.1}/docs/assets/signals.svg +0 -0
  49. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/_artifacts.py +0 -0
  50. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/__init__.py +0 -0
  51. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/band.py +0 -0
  52. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/features.py +0 -0
  53. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/prep_page.py +0 -0
  54. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/tokenizer.py +0 -0
  55. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/core/torch_infer.py +0 -0
  56. {htmlsift-0.1.1 → htmlsift-0.2.1}/src/htmlsift/py.typed +0 -0
  57. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/__init__.py +0 -0
  58. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.extracted.html +0 -0
  59. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.extracted.md +0 -0
  60. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.html +0 -0
  61. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/data/sample.rendered.txt +0 -0
  62. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_features.py +0 -0
  63. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_manifest.py +0 -0
  64. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_prep_page.py +0 -0
  65. {htmlsift-0.1.1 → htmlsift-0.2.1}/tests/test_render_golden.py +0 -0
  66. {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.1.1
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 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:
@@ -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
@@ -28,6 +28,7 @@ dependencies = [
28
28
  "lxml",
29
29
  "numpy",
30
30
  "huggingface-hub",
31
+ "w3lib>=2.5,<3",
31
32
  ]
32
33
 
33
34
  [project.optional-dependencies]
@@ -4,6 +4,6 @@
4
4
  """
5
5
  from .extractor import Extractor, extract
6
6
 
7
- __version__ = "0.1.1"
7
+ __version__ = "0.2.1"
8
8
 
9
9
  __all__ = ["Extractor", "extract"]
@@ -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