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.
Files changed (57) hide show
  1. htmlsift-0.2.0/CHANGELOG.md +72 -0
  2. {htmlsift-0.1.1 → htmlsift-0.2.0}/PKG-INFO +30 -2
  3. {htmlsift-0.1.1 → htmlsift-0.2.0}/README.md +63 -1
  4. {htmlsift-0.1.1 → htmlsift-0.2.0}/README.pypi.md +28 -1
  5. {htmlsift-0.1.1 → htmlsift-0.2.0}/pyproject.toml +1 -0
  6. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/__init__.py +1 -1
  7. htmlsift-0.2.0/src/htmlsift/_decode.py +45 -0
  8. htmlsift-0.2.0/src/htmlsift/_links.py +52 -0
  9. htmlsift-0.2.0/src/htmlsift/cli.py +135 -0
  10. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/prune.py +5 -4
  11. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/extractor.py +26 -12
  12. htmlsift-0.2.0/tests/conftest.py +16 -0
  13. htmlsift-0.2.0/tests/data/article_fi.html +32 -0
  14. htmlsift-0.2.0/tests/data/article_ja.html +29 -0
  15. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_api.py +32 -0
  16. htmlsift-0.2.0/tests/test_cli.py +167 -0
  17. htmlsift-0.2.0/tests/test_decode.py +153 -0
  18. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_extract_golden.py +29 -0
  19. htmlsift-0.2.0/tests/test_links.py +163 -0
  20. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_prune.py +20 -0
  21. htmlsift-0.1.1/CHANGELOG.md +0 -23
  22. htmlsift-0.1.1/src/htmlsift/cli.py +0 -29
  23. {htmlsift-0.1.1 → htmlsift-0.2.0}/.gitattributes +0 -0
  24. {htmlsift-0.1.1 → htmlsift-0.2.0}/.github/workflows/ci.yml +0 -0
  25. {htmlsift-0.1.1 → htmlsift-0.2.0}/.github/workflows/publish.yml +0 -0
  26. {htmlsift-0.1.1 → htmlsift-0.2.0}/.gitignore +0 -0
  27. {htmlsift-0.1.1 → htmlsift-0.2.0}/AGENTS.md +0 -0
  28. {htmlsift-0.1.1 → htmlsift-0.2.0}/LICENSE +0 -0
  29. {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/annotation-example.svg +0 -0
  30. {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/architecture.svg +0 -0
  31. {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/concept.svg +0 -0
  32. {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/cpu_speed_accuracy.svg +0 -0
  33. {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/data-efficiency.svg +0 -0
  34. {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/gpu_speed_accuracy_lite.svg +0 -0
  35. {htmlsift-0.1.1 → htmlsift-0.2.0}/docs/assets/signals.svg +0 -0
  36. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/_artifacts.py +0 -0
  37. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/__init__.py +0 -0
  38. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/band.py +0 -0
  39. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/features.py +0 -0
  40. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/onnx_infer.py +0 -0
  41. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/prep_page.py +0 -0
  42. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/render.py +0 -0
  43. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/tokenizer.py +0 -0
  44. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/core/torch_infer.py +0 -0
  45. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/loader.py +0 -0
  46. {htmlsift-0.1.1 → htmlsift-0.2.0}/src/htmlsift/py.typed +0 -0
  47. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/__init__.py +0 -0
  48. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.extracted.html +0 -0
  49. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.extracted.md +0 -0
  50. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.extracted.txt +0 -0
  51. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.html +0 -0
  52. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/data/sample.rendered.txt +0 -0
  53. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_features.py +0 -0
  54. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_manifest.py +0 -0
  55. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_prep_page.py +0 -0
  56. {htmlsift-0.1.1 → htmlsift-0.2.0}/tests/test_render_golden.py +0 -0
  57. {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.1.1
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
@@ -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.0"
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,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 keep:
16
+ if el in expanded:
17
17
  continue
18
- keep.add(el)
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, to_html
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 an HTML string.
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
- root, blocks, block_src, probs = self._predict(html)
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 == "html":
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