htmlsift 0.1.1__tar.gz → 0.2.0__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.0/CHANGELOG.md +72 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/PKG-INFO +30 -2
- {htmlsift-0.1.1 → htmlsift-0.2.0}/README.md +63 -1
- {htmlsift-0.1.1 → htmlsift-0.2.0}/README.pypi.md +28 -1
- {htmlsift-0.1.1 → htmlsift-0.2.0}/pyproject.toml +1 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/__init__.py +1 -1
- htmlsift-0.2.0/src/htmlsift/_decode.py +45 -0
- htmlsift-0.2.0/src/htmlsift/_links.py +52 -0
- htmlsift-0.2.0/src/htmlsift/cli.py +135 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/prune.py +5 -4
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/extractor.py +26 -12
- htmlsift-0.2.0/tests/conftest.py +16 -0
- htmlsift-0.2.0/tests/data/article_fi.html +32 -0
- htmlsift-0.2.0/tests/data/article_ja.html +29 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_api.py +32 -0
- htmlsift-0.2.0/tests/test_cli.py +167 -0
- htmlsift-0.2.0/tests/test_decode.py +153 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_extract_golden.py +29 -0
- htmlsift-0.2.0/tests/test_links.py +163 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_prune.py +20 -0
- htmlsift-0.1.1/CHANGELOG.md +0 -23
- htmlsift-0.1.1/src/htmlsift/cli.py +0 -29
- {htmlsift-0.1.1 → htmlsift-0.2.0}/.gitattributes +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/.github/workflows/ci.yml +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/.github/workflows/publish.yml +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/.gitignore +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/AGENTS.md +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/LICENSE +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/annotation-example.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/architecture.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/concept.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/cpu_speed_accuracy.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/data-efficiency.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/gpu_speed_accuracy_lite.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/signals.svg +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/_artifacts.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/__init__.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/band.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/features.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/onnx_infer.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/prep_page.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/render.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/tokenizer.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/torch_infer.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/loader.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/py.typed +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/__init__.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.extracted.html +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.extracted.md +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.extracted.txt +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.html +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.rendered.txt +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_features.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_manifest.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_prep_page.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_render_golden.py +0 -0
- {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_render_links.py +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
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.0] - 2026-11-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `extract()` accepts `bytes` and decodes them: byte order mark, then `encoding=`, then the
|
|
13
|
+
page's `<meta>` declaration, then utf-8, falling back to cp1252. Legacy charset labels map
|
|
14
|
+
the way browsers read them (`latin1` → cp1252, `shift_jis` → cp932, `gb2312` → gb18030).
|
|
15
|
+
- Keyword-only `encoding=` on `Extractor.extract` and `extract`, for a charset known from
|
|
16
|
+
an HTTP or WARC header.
|
|
17
|
+
- Dependency: `w3lib` (BSD-3-Clause) for charset sniffing.
|
|
18
|
+
- Keyword-only `base_url=` on `Extractor.extract` and `extract`: links and image URLs in
|
|
19
|
+
`html` and `markdown` output are made absolute against it. The page's first
|
|
20
|
+
`<base href>` is resolved against `base_url`; malformed links are left as they are.
|
|
21
|
+
`text` output and `with_blocks` are unaffected.
|
|
22
|
+
- `htmlsift extract` command: files, quoted globs or stdin in; the content of a single
|
|
23
|
+
input, or one JSON line per file (`--jsonl` to force), out. Flags `--output`,
|
|
24
|
+
`--mode`, `--device`, `--base-url`, `--encoding`. Failed files are reported as
|
|
25
|
+
records and the run exits with 1.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Input other than `str` or `bytes` raises `TypeError`; `extract(None)` previously
|
|
29
|
+
returned `""`.
|
|
30
|
+
- `encoding=` with `str` input raises `TypeError`, since a `str` is already decoded.
|
|
31
|
+
- A page with an absolute `<base href>` gets absolute links in `html` and `markdown` output
|
|
32
|
+
even without `base_url`. Previously `<base>` was dropped with `<head>`, leaving its links
|
|
33
|
+
relative.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- Non-utf-8 `bytes` input crashed with `UnicodeDecodeError`.
|
|
37
|
+
- `html` and `markdown` output could drop images and other inline content inside a kept
|
|
38
|
+
block, varying from run to run on the same page. `text` output was unaffected.
|
|
39
|
+
|
|
40
|
+
## [0.1.1] - 2026-09-23
|
|
41
|
+
|
|
42
|
+
Documentation and packaging metadata only — no code or model changes since 0.1.0.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
- Project URLs in package metadata (research repo, Hugging Face weights, issue tracker).
|
|
46
|
+
- PyPI Trusted Publishing workflow (`.github/workflows/publish.yml`).
|
|
47
|
+
- Cross-benchmark accuracy table (`base`/`mini` vs trafilatura, readability, resiliparse on
|
|
48
|
+
WMB, WCXB, DAnIEL) and an "Annotation is a policy" section in the README.
|
|
49
|
+
- GPU-on-Windows install note (CUDA torch from the PyTorch index).
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- Stated the model-weight license (Apache-2.0) and added WebMainBench attribution.
|
|
53
|
+
- Cited the WebMainBench source paper (Liu et al., *Dripper*, arXiv:2511.23119).
|
|
54
|
+
- Clarified model shorthand and tightened copy in the README and the PyPI description.
|
|
55
|
+
|
|
56
|
+
## [0.1.0] - 2026-09-22
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
- Initial release.
|
|
60
|
+
- `mini` model (int8 ONNX, runs on CPU) and `base` model (311M encoder, GPU-capable),
|
|
61
|
+
installed via `pip install htmlsift` and `pip install htmlsift[full]` respectively.
|
|
62
|
+
- `Extractor(mode, *, bundle_dir, threads, device)` with
|
|
63
|
+
`.extract(html, output=..., with_blocks=...)`, plus the module-level `extract(...)`.
|
|
64
|
+
- Output formats: plain text, HTML, and markdown.
|
|
65
|
+
- Device selection for `base`: `device="cuda"` / `"cpu"`, auto-detecting a GPU and
|
|
66
|
+
warning on CPU fallback.
|
|
67
|
+
- Model bundles fetched from the Hugging Face Hub, pinned to a fixed commit revision.
|
|
68
|
+
|
|
69
|
+
[Unreleased]: https://github.com/koivualeksi/htmlsift/compare/v0.2.0...HEAD
|
|
70
|
+
[0.2.0]: https://github.com/koivualeksi/htmlsift/compare/v0.1.1...v0.2.0
|
|
71
|
+
[0.1.1]: https://github.com/koivualeksi/htmlsift/compare/v0.1.0...v0.1.1
|
|
72
|
+
[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.0
|
|
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
|
|
|
@@ -96,6 +97,21 @@ import htmlsift
|
|
|
96
97
|
htmlsift.extract(html) # module-level convenience
|
|
97
98
|
```
|
|
98
99
|
|
|
100
|
+
Raw bytes are decoded for you (byte order mark, then `encoding=`, then `<meta charset>`,
|
|
101
|
+
then utf-8 / cp1252):
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
ex.extract(response.content) # charset from the page itself
|
|
105
|
+
ex.extract(payload, encoding="windows-1251") # charset from the HTTP / WARC header
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Make links and image URLs absolute in `html` / `markdown` output (the page's `<base href>`
|
|
109
|
+
is honoured):
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
|
|
113
|
+
```
|
|
114
|
+
|
|
99
115
|
The larger model, from `htmlsift[full]`:
|
|
100
116
|
|
|
101
117
|
```python
|
|
@@ -113,6 +129,18 @@ ex = Extractor("base", device="cuda") # require the GPU
|
|
|
113
129
|
| `html` | selected DOM, with structure / links / images kept |
|
|
114
130
|
| `markdown` | clean markdown, links and images preserved |
|
|
115
131
|
|
|
132
|
+
## Command line
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
htmlsift extract page.html # main content of one page
|
|
136
|
+
curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
|
|
137
|
+
htmlsift extract "crawl/*.html" --output markdown > out.jsonl # one JSON line per file
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--jsonl` (records
|
|
141
|
+
even for a single file). Failed files become `{"path", "error"}` records and the exit code
|
|
142
|
+
is 1. Output is utf-8.
|
|
143
|
+
|
|
116
144
|
## License
|
|
117
145
|
|
|
118
146
|
Apache-2.0. Model weights are distributed from the Hugging Face Hub, also under Apache-2.0, and
|
|
@@ -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
|
|
|
@@ -92,6 +92,36 @@ import htmlsift
|
|
|
92
92
|
htmlsift.extract(html) # default mini
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
### Bytes and encodings
|
|
96
|
+
|
|
97
|
+
Pass raw bytes from a file, an HTTP response or a WARC record, and htmlsift decodes them:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
ex.extract(response.content) # charset from the page itself
|
|
101
|
+
ex.extract(payload, encoding="windows-1251") # charset from the HTTP / WARC header
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The charset is taken from, in order: a byte order mark, `encoding=`, the page's
|
|
105
|
+
`<meta charset>`; an undeclared page is read as utf-8, falling back to cp1252. Unknown
|
|
106
|
+
labels are skipped and undecodable bytes become `�`. A `str` counts as already decoded, so
|
|
107
|
+
`encoding=` with a `str` raises `TypeError`. Prefer passing bytes over a `str` decoded
|
|
108
|
+
upstream: `requests`' `response.text`, for one, guesses ISO-8859-1 for pages without a
|
|
109
|
+
charset header.
|
|
110
|
+
|
|
111
|
+
### Absolute links
|
|
112
|
+
|
|
113
|
+
Pass the page's URL to make links and image URLs absolute in `html` and `markdown` output:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
|
|
117
|
+
# [the report](report) -> [the report](https://example.com/blog/report)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The page's first `<base href>` is resolved against `base_url`, as a browser does. Without
|
|
121
|
+
`base_url`, an absolute `<base href>` is used on its own; with neither, links stay as they
|
|
122
|
+
are. Malformed links are left unchanged. `text` output has no links, so it ignores
|
|
123
|
+
`base_url`.
|
|
124
|
+
|
|
95
125
|
The larger model, from the `[full]` install:
|
|
96
126
|
|
|
97
127
|
```python
|
|
@@ -136,6 +166,35 @@ Composite image showing the global distribution of photosynthesis, including bot
|
|
|
136
166
|
| `markdown` | clean markdown, links and images preserved |
|
|
137
167
|
|
|
138
168
|
|
|
169
|
+
## Command line
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
htmlsift extract page.html # main content of one page
|
|
173
|
+
htmlsift extract page.html --output markdown --base-url https://example.com/page
|
|
174
|
+
curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
|
|
175
|
+
htmlsift extract "crawl/*.html" > out.jsonl # one JSON record per file
|
|
176
|
+
htmlsift extract "crawl/**/*.html" --mode base --device cuda > out.jsonl
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
One file or `-` (stdin) prints the content itself. Several files or a glob print one JSON
|
|
180
|
+
line per file, `{"path": ..., "content": ...}`, or `{"path": ..., "error": ...}` for a file
|
|
181
|
+
that failed; the run continues and exits with 1 at the end. Pass `--jsonl` to get records
|
|
182
|
+
for a single file too, which scripts should do: bash expands an unquoted `*.html` before
|
|
183
|
+
htmlsift sees it. Quoted globs are expanded by htmlsift itself (`**` recurses), so they
|
|
184
|
+
work the same in every shell; directories are not walked.
|
|
185
|
+
|
|
186
|
+
| flag | |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `--output text\|html\|markdown` | as `output=` above (default `text`) |
|
|
189
|
+
| `--mode mini\|base`, `--device` | model and torch device, as for `Extractor` |
|
|
190
|
+
| `--base-url URL` | make links absolute; applies to every input |
|
|
191
|
+
| `--encoding ENC` | charset of the input bytes; applies to every input |
|
|
192
|
+
| `--jsonl` | always print JSON records |
|
|
193
|
+
|
|
194
|
+
Files are read as bytes, so the charset is detected as described above. Output is utf-8.
|
|
195
|
+
Exit codes: 0 ok, 1 a file or the model failed, 2 a usage error. In Windows PowerShell 5.1,
|
|
196
|
+
`>` re-encodes a program's output; redirect from bash, cmd or PowerShell 7 instead.
|
|
197
|
+
|
|
139
198
|
## Concept
|
|
140
199
|
|
|
141
200
|
<details>
|
|
@@ -201,6 +260,9 @@ and its `docs/REPRODUCE.md`.
|
|
|
201
260
|
A standalone hero image that is its own block renders to no text line, so it can't be selected
|
|
202
261
|
today. Inline images inside a kept block are preserved in `html` and `markdown`.
|
|
203
262
|
|
|
263
|
+
`base_url` does not rewrite `srcset`, so responsive images in `html` output keep relative
|
|
264
|
+
candidates (`src` is rewritten; `markdown` uses only `src`).
|
|
265
|
+
|
|
204
266
|
</details>
|
|
205
267
|
|
|
206
268
|
## 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
|
|
|
@@ -65,6 +65,21 @@ import htmlsift
|
|
|
65
65
|
htmlsift.extract(html) # module-level convenience
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
Raw bytes are decoded for you (byte order mark, then `encoding=`, then `<meta charset>`,
|
|
69
|
+
then utf-8 / cp1252):
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
ex.extract(response.content) # charset from the page itself
|
|
73
|
+
ex.extract(payload, encoding="windows-1251") # charset from the HTTP / WARC header
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Make links and image URLs absolute in `html` / `markdown` output (the page's `<base href>`
|
|
77
|
+
is honoured):
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
ex.extract(html, output="markdown", base_url="https://example.com/blog/post")
|
|
81
|
+
```
|
|
82
|
+
|
|
68
83
|
The larger model, from `htmlsift[full]`:
|
|
69
84
|
|
|
70
85
|
```python
|
|
@@ -82,6 +97,18 @@ ex = Extractor("base", device="cuda") # require the GPU
|
|
|
82
97
|
| `html` | selected DOM, with structure / links / images kept |
|
|
83
98
|
| `markdown` | clean markdown, links and images preserved |
|
|
84
99
|
|
|
100
|
+
## Command line
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
htmlsift extract page.html # main content of one page
|
|
104
|
+
curl -s https://example.com/a | htmlsift extract - --base-url https://example.com/a
|
|
105
|
+
htmlsift extract "crawl/*.html" --output markdown > out.jsonl # one JSON line per file
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Flags: `--output`, `--mode`, `--device`, `--base-url`, `--encoding`, `--jsonl` (records
|
|
109
|
+
even for a single file). Failed files become `{"path", "error"}` records and the exit code
|
|
110
|
+
is 1. Output is utf-8.
|
|
111
|
+
|
|
85
112
|
## License
|
|
86
113
|
|
|
87
114
|
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,135 @@
|
|
|
1
|
+
"""`htmlsift` command line.
|
|
2
|
+
|
|
3
|
+
htmlsift extract page.html # main content of one page
|
|
4
|
+
htmlsift extract "crawl/*.html" > out.jsonl # one JSON record per file
|
|
5
|
+
curl -s URL | htmlsift extract - --base-url URL
|
|
6
|
+
htmlsift download mini # prefetch a model bundle
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import errno
|
|
10
|
+
import glob
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from w3lib.encoding import resolve_encoding
|
|
17
|
+
|
|
18
|
+
from ._artifacts import BUNDLE_FILES, resolve_bundle
|
|
19
|
+
from .extractor import Extractor
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _download(mode):
|
|
23
|
+
paths = resolve_bundle(mode) # hf_hub_download each file -> cached
|
|
24
|
+
print(f"{mode}: cached {len(paths)} files")
|
|
25
|
+
for name, p in paths.items():
|
|
26
|
+
print(f" {name:10} {p}")
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_glob(arg):
|
|
31
|
+
"""A pattern to expand: not stdin, not an existing path, and has glob characters."""
|
|
32
|
+
return arg != "-" and not os.path.exists(arg) and any(c in arg for c in "*?[")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve(arg):
|
|
36
|
+
"""One argument -> [(path, error)]; error is None for an input to read."""
|
|
37
|
+
if os.path.isdir(arg):
|
|
38
|
+
return [(arg, f'is a directory; use "{arg}/*.html"')]
|
|
39
|
+
if _is_glob(arg):
|
|
40
|
+
matches = sorted(p for p in glob.glob(arg, recursive=True) if not os.path.isdir(p))
|
|
41
|
+
return [(Path(p).as_posix(), None) for p in matches] or [(arg, "no files match")]
|
|
42
|
+
return [(arg, None)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read(path):
|
|
46
|
+
return sys.stdin.buffer.read() if path == "-" else Path(path).read_bytes()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _PipeClosed(Exception):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write(text):
|
|
54
|
+
try:
|
|
55
|
+
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
56
|
+
sys.stdout.buffer.flush()
|
|
57
|
+
except OSError as e:
|
|
58
|
+
# Reader closed the pipe (e.g. `| head`); Windows reports it as EINVAL.
|
|
59
|
+
if isinstance(e, BrokenPipeError) or e.errno == errno.EINVAL:
|
|
60
|
+
raise _PipeClosed from e
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _emit(path, content, error, raw):
|
|
65
|
+
if raw and error is not None:
|
|
66
|
+
print(f"htmlsift: {path}: {error}", file=sys.stderr)
|
|
67
|
+
elif raw:
|
|
68
|
+
_write(content.rstrip("\n") + "\n")
|
|
69
|
+
else:
|
|
70
|
+
rec = {"path": path, "error": error} if error is not None else {"path": path,
|
|
71
|
+
"content": content}
|
|
72
|
+
_write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract(ap, args):
|
|
76
|
+
if args.inputs.count("-") > 1:
|
|
77
|
+
ap.error("'-' (stdin) can be given only once")
|
|
78
|
+
if args.encoding is not None and resolve_encoding(args.encoding.strip()) is None:
|
|
79
|
+
ap.error(f"unknown encoding: {args.encoding}")
|
|
80
|
+
ex = Extractor(args.mode, device=args.device)
|
|
81
|
+
try:
|
|
82
|
+
_ = ex.model
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"htmlsift: cannot load model: {e}", file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
raw = len(args.inputs) == 1 and not _is_glob(args.inputs[0]) and not args.jsonl
|
|
87
|
+
failed = False
|
|
88
|
+
try:
|
|
89
|
+
for arg in args.inputs:
|
|
90
|
+
for path, error in _resolve(arg):
|
|
91
|
+
content = None
|
|
92
|
+
if error is None:
|
|
93
|
+
try:
|
|
94
|
+
content = ex.extract(_read(path), output=args.output,
|
|
95
|
+
encoding=args.encoding, base_url=args.base_url)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
error = str(e) or type(e).__name__
|
|
98
|
+
failed = failed or error is not None
|
|
99
|
+
_emit(path, content, error, raw)
|
|
100
|
+
except _PipeClosed:
|
|
101
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
102
|
+
return 1
|
|
103
|
+
return 1 if failed else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv=None):
|
|
107
|
+
ap = argparse.ArgumentParser(prog="htmlsift")
|
|
108
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
109
|
+
|
|
110
|
+
dl = sub.add_parser("download", help="prefetch a model bundle into the HF cache")
|
|
111
|
+
dl.add_argument("mode", choices=list(BUNDLE_FILES))
|
|
112
|
+
|
|
113
|
+
ex = sub.add_parser(
|
|
114
|
+
"extract", help="extract main content from HTML files",
|
|
115
|
+
description="Extract main content. One file or '-' (stdin) prints the content; "
|
|
116
|
+
"several files or a glob print one JSON record per file.")
|
|
117
|
+
ex.add_argument("inputs", nargs="+", metavar="INPUT",
|
|
118
|
+
help="HTML file, glob pattern (quote it; ** recurses), or - for stdin")
|
|
119
|
+
ex.add_argument("--output", choices=["text", "html", "markdown"], default="text")
|
|
120
|
+
ex.add_argument("--mode", choices=list(BUNDLE_FILES), default="mini")
|
|
121
|
+
ex.add_argument("--device", help="torch device for --mode base, e.g. cuda or cpu")
|
|
122
|
+
ex.add_argument("--base-url", help="make links absolute (html/markdown); applies to "
|
|
123
|
+
"every input")
|
|
124
|
+
ex.add_argument("--encoding", help="charset of the input bytes; applies to every input")
|
|
125
|
+
ex.add_argument("--jsonl", action="store_true",
|
|
126
|
+
help="always print JSON records, even for a single input")
|
|
127
|
+
|
|
128
|
+
args = ap.parse_args(argv)
|
|
129
|
+
if args.cmd == "download":
|
|
130
|
+
return _download(args.mode)
|
|
131
|
+
return _extract(ap, args)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
sys.exit(main())
|
|
@@ -11,13 +11,14 @@ from lxml import html as lxml_html
|
|
|
11
11
|
def keep_set(elements):
|
|
12
12
|
"""The nodes to keep: each element, its ancestors, and its descendants. Holding the
|
|
13
13
|
proxies in the set keeps their lxml identity stable for the prune walk."""
|
|
14
|
-
keep = set()
|
|
14
|
+
keep, expanded = set(), set()
|
|
15
15
|
for el in elements:
|
|
16
|
-
if el in
|
|
16
|
+
if el in expanded:
|
|
17
17
|
continue
|
|
18
|
-
|
|
18
|
+
sub = {el, *el.iterdescendants()}
|
|
19
|
+
expanded |= sub
|
|
20
|
+
keep |= sub
|
|
19
21
|
keep.update(el.iterancestors())
|
|
20
|
-
keep.update(el.iterdescendants())
|
|
21
22
|
return keep
|
|
22
23
|
|
|
23
24
|
|
|
@@ -4,9 +4,12 @@ over threshold, serialize. Mirrors the research inference front-end (render_bloc
|
|
|
4
4
|
prep_page + threshold); no sanitize step.
|
|
5
5
|
"""
|
|
6
6
|
import numpy as np
|
|
7
|
+
from lxml import html as lxml_html
|
|
7
8
|
|
|
9
|
+
from ._decode import prepare_input
|
|
10
|
+
from ._links import effective_base, make_absolute
|
|
8
11
|
from .core.prep_page import prep_page
|
|
9
|
-
from .core.prune import prune
|
|
12
|
+
from .core.prune import prune
|
|
10
13
|
from .core.render import parse, render_tree
|
|
11
14
|
from .loader import load_model
|
|
12
15
|
|
|
@@ -36,33 +39,42 @@ class Extractor:
|
|
|
36
39
|
threads=self._threads, device=self._device)
|
|
37
40
|
return self._model
|
|
38
41
|
|
|
39
|
-
def extract(self, html, output="text", with_blocks=False):
|
|
40
|
-
"""Extract main content from
|
|
42
|
+
def extract(self, html, output="text", with_blocks=False, *, encoding=None, base_url=None):
|
|
43
|
+
"""Extract main content from HTML, given as str or bytes.
|
|
41
44
|
|
|
45
|
+
bytes are decoded by BOM, then encoding= (e.g. an HTTP or WARC charset), then the
|
|
46
|
+
page's <meta> declaration, then utf-8 / cp1252. encoding= with str raises TypeError.
|
|
42
47
|
output="text" joins the selected lines; output="html" prunes the DOM to the
|
|
43
48
|
selected blocks (structure, links, images kept) and serializes. with_blocks=True
|
|
44
|
-
returns (blocks, probs) -- every block and its probability, unthresholded.
|
|
45
|
-
|
|
49
|
+
returns (blocks, probs) -- every block and its probability, unthresholded.
|
|
50
|
+
base_url= makes links absolute in html/markdown output; the page's first
|
|
51
|
+
<base href> is resolved against it, or used alone if absolute."""
|
|
52
|
+
if base_url is not None and not isinstance(base_url, str):
|
|
53
|
+
raise TypeError(f"base_url must be str or None, not {type(base_url).__name__}")
|
|
54
|
+
root, blocks, block_src, probs = self._predict(html, encoding)
|
|
46
55
|
if with_blocks:
|
|
47
56
|
return blocks, probs
|
|
48
57
|
if output == "text":
|
|
49
58
|
return "\n".join(b for b, p in zip(blocks, probs, strict=True) if p > THRESHOLD)
|
|
50
|
-
if output
|
|
51
|
-
selected = _selected_srcs(block_src, probs)
|
|
52
|
-
return to_html(root, selected) if selected else ""
|
|
53
|
-
if output == "markdown":
|
|
59
|
+
if output in ("html", "markdown"):
|
|
54
60
|
selected = _selected_srcs(block_src, probs)
|
|
55
61
|
if not selected:
|
|
56
62
|
return ""
|
|
63
|
+
base = effective_base(root, base_url)
|
|
57
64
|
prune(root, selected)
|
|
65
|
+
if base:
|
|
66
|
+
make_absolute(root, base)
|
|
67
|
+
if output == "html":
|
|
68
|
+
return lxml_html.tostring(root, encoding="unicode")
|
|
58
69
|
lines, _ = render_tree(root, links=True)
|
|
59
70
|
return "\n".join(lines).strip("\n")
|
|
60
71
|
raise ValueError(f"unknown output {output!r}; expected 'text', 'html', or 'markdown'")
|
|
61
72
|
|
|
62
|
-
def _predict(self, html):
|
|
73
|
+
def _predict(self, html, encoding=None):
|
|
63
74
|
"""html -> (root, blocks, block_src, probs). blocks are the rstripped non-empty
|
|
64
75
|
render lines; block_src[i] the DOM elements behind block i. Empty page ->
|
|
65
76
|
(None, [], [], empty array)."""
|
|
77
|
+
html = prepare_input(html, encoding)
|
|
66
78
|
if not html or not html.strip():
|
|
67
79
|
return None, [], [], np.empty(0, dtype=np.float32)
|
|
68
80
|
root = parse(html)
|
|
@@ -83,9 +95,11 @@ class Extractor:
|
|
|
83
95
|
_CACHE = {}
|
|
84
96
|
|
|
85
97
|
|
|
86
|
-
def extract(html, mode="mini", output="text", with_blocks=False
|
|
98
|
+
def extract(html, mode="mini", output="text", with_blocks=False, *, encoding=None,
|
|
99
|
+
base_url=None):
|
|
87
100
|
"""Module-level convenience: extract with a per-mode cached Extractor."""
|
|
88
101
|
ex = _CACHE.get(mode)
|
|
89
102
|
if ex is None:
|
|
90
103
|
ex = _CACHE[mode] = Extractor(mode)
|
|
91
|
-
return ex.extract(html, output=output, with_blocks=with_blocks
|
|
104
|
+
return ex.extract(html, output=output, with_blocks=with_blocks, encoding=encoding,
|
|
105
|
+
base_url=base_url)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from htmlsift import Extractor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def keep_all(monkeypatch):
|
|
11
|
+
"""An Extractor whose dummy model scores every line 1.0."""
|
|
12
|
+
monkeypatch.setattr("htmlsift.extractor.prep_page", lambda tok, blocks, *a, **k: blocks)
|
|
13
|
+
ex = Extractor()
|
|
14
|
+
ex._model = SimpleNamespace(features=lambda lines, src: None, tok=None, window=0, cap=0,
|
|
15
|
+
infer_fn=lambda blocks: np.ones(len(blocks), dtype=np.float32))
|
|
16
|
+
return ex
|