html-reader-llm 0.0.1__py3-none-any.whl
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.
- html_reader_llm/__init__.py +39 -0
- html_reader_llm/browser.py +161 -0
- html_reader_llm/cli.py +563 -0
- html_reader_llm/llm.py +502 -0
- html_reader_llm/render_detect.py +415 -0
- html_reader_llm/selector_validator.py +177 -0
- html_reader_llm/settings.py +73 -0
- html_reader_llm/simplify.py +547 -0
- html_reader_llm-0.0.1.dist-info/METADATA +52 -0
- html_reader_llm-0.0.1.dist-info/RECORD +13 -0
- html_reader_llm-0.0.1.dist-info/WHEEL +4 -0
- html_reader_llm-0.0.1.dist-info/entry_points.txt +3 -0
- html_reader_llm-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""HTML simplification pipeline — transforms raw HTML into LLM-friendly format.
|
|
2
|
+
|
|
3
|
+
Fixed 7-step pipeline:
|
|
4
|
+
1. Strip noise tags (script/style/noscript etc.) + comments
|
|
5
|
+
2. Simplify media (img/video/audio → placeholders)
|
|
6
|
+
3. Clean attributes (blacklist/whitelist dual mode) + URL normalization
|
|
7
|
+
4. Truncate repeated list items
|
|
8
|
+
5. Truncate text nodes (regular + script inline)
|
|
9
|
+
6. Unwrap bare wrapper tags
|
|
10
|
+
7. Clean whitespace
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import PurePosixPath
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
22
|
+
|
|
23
|
+
from html_reader_llm import settings # noqa: E402 — relative import for package copy
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# --- Noise tags: entire subtree removed ---
|
|
28
|
+
NOISE_TAGS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"style",
|
|
31
|
+
"noscript",
|
|
32
|
+
"iframe",
|
|
33
|
+
"object",
|
|
34
|
+
"embed",
|
|
35
|
+
"base",
|
|
36
|
+
"font",
|
|
37
|
+
"link",
|
|
38
|
+
"br",
|
|
39
|
+
"hr",
|
|
40
|
+
"header",
|
|
41
|
+
"nav",
|
|
42
|
+
"footer",
|
|
43
|
+
"svg",
|
|
44
|
+
"path",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# --- Keep tags: never unwrapped by bare-tag removal ---
|
|
49
|
+
KEEP_TAGS = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"html",
|
|
52
|
+
"head",
|
|
53
|
+
"body",
|
|
54
|
+
"title",
|
|
55
|
+
"meta",
|
|
56
|
+
"table",
|
|
57
|
+
"thead",
|
|
58
|
+
"tbody",
|
|
59
|
+
"tfoot",
|
|
60
|
+
"tr",
|
|
61
|
+
"th",
|
|
62
|
+
"td",
|
|
63
|
+
"ul",
|
|
64
|
+
"ol",
|
|
65
|
+
"li",
|
|
66
|
+
"dl",
|
|
67
|
+
"dt",
|
|
68
|
+
"dd",
|
|
69
|
+
"h1",
|
|
70
|
+
"h2",
|
|
71
|
+
"h3",
|
|
72
|
+
"h4",
|
|
73
|
+
"h5",
|
|
74
|
+
"h6",
|
|
75
|
+
"p",
|
|
76
|
+
"a",
|
|
77
|
+
"img",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# --- Structural tags: protect parent from unwrap ---
|
|
82
|
+
STRUCTURE_TAGS = frozenset(
|
|
83
|
+
{
|
|
84
|
+
"ul",
|
|
85
|
+
"ol",
|
|
86
|
+
"li",
|
|
87
|
+
"dl",
|
|
88
|
+
"dt",
|
|
89
|
+
"dd",
|
|
90
|
+
"table",
|
|
91
|
+
"thead",
|
|
92
|
+
"tbody",
|
|
93
|
+
"tfoot",
|
|
94
|
+
"tr",
|
|
95
|
+
"th",
|
|
96
|
+
"td",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# --- Truncatable item tags ---
|
|
101
|
+
TRUNCATABLE_ITEM_TAGS = frozenset({"li", "tr"})
|
|
102
|
+
|
|
103
|
+
# --- Unit regex for text truncation ---
|
|
104
|
+
_UNIT_RE = re.compile(
|
|
105
|
+
r"\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?"
|
|
106
|
+
r"|\d{1,2}:\d{2}(?::\d{2})?"
|
|
107
|
+
r"|[\u4e00-\u9fff\u3400-\u4dbf]"
|
|
108
|
+
r"|[a-zA-Z]+"
|
|
109
|
+
r"|\d+"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
_MULTI_WS_RE = re.compile(r"\s+")
|
|
113
|
+
_SHORTENED_PLACEHOLDER = "[省略{n}字]"
|
|
114
|
+
|
|
115
|
+
# --- Default noise CSS selectors ---
|
|
116
|
+
DEFAULT_NOISE_SELECTORS: tuple[str, ...] = (
|
|
117
|
+
".header",
|
|
118
|
+
".footer",
|
|
119
|
+
".nav",
|
|
120
|
+
".navbar",
|
|
121
|
+
".sidebar",
|
|
122
|
+
"#sidebar",
|
|
123
|
+
".ad",
|
|
124
|
+
".ads",
|
|
125
|
+
".advertisement",
|
|
126
|
+
"[class*='banner']",
|
|
127
|
+
"[class*='cookie']",
|
|
128
|
+
"[class*='popup']",
|
|
129
|
+
"[class*='modal']",
|
|
130
|
+
"[id*='ad-']",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Helpers
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _walk_nodes(node: Any) -> Any:
|
|
140
|
+
"""Iterative DFS over DOM tree, yielding all nodes including text nodes.
|
|
141
|
+
|
|
142
|
+
selectolax lexbor's traverse() skips text nodes,
|
|
143
|
+
so we manually walk via first_child / next.
|
|
144
|
+
Explicit stack avoids recursion depth issues on deep DOMs.
|
|
145
|
+
"""
|
|
146
|
+
stack = [node]
|
|
147
|
+
while stack:
|
|
148
|
+
current = stack.pop()
|
|
149
|
+
yield current
|
|
150
|
+
children: list[Any] = []
|
|
151
|
+
child = current.first_child
|
|
152
|
+
while child is not None:
|
|
153
|
+
children.append(child)
|
|
154
|
+
child = child.next
|
|
155
|
+
stack.extend(reversed(children))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _normalize_url(url: str) -> str:
|
|
159
|
+
"""Convert a URL to its last path segment, stripping query and fragment.
|
|
160
|
+
|
|
161
|
+
Examples:
|
|
162
|
+
/static/jquery.min.js → jquery.min.js
|
|
163
|
+
https://cdn.example.com/a.js → a.js
|
|
164
|
+
/page?id=1#section → page
|
|
165
|
+
/ → /
|
|
166
|
+
"""
|
|
167
|
+
if not url or url.strip() == "/":
|
|
168
|
+
return url.strip() if url else ""
|
|
169
|
+
try:
|
|
170
|
+
parsed = urlparse(url)
|
|
171
|
+
path = parsed.path
|
|
172
|
+
except Exception: # noqa: BLE001
|
|
173
|
+
path = url
|
|
174
|
+
basename = PurePosixPath(path).name
|
|
175
|
+
return basename if basename else path
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _human_size(data_len: int) -> str:
|
|
179
|
+
"""Convert byte count to human-readable size string."""
|
|
180
|
+
if data_len < 1024:
|
|
181
|
+
return f"{data_len}B"
|
|
182
|
+
if data_len < 1024 * 1024:
|
|
183
|
+
return f"{data_len / 1024:.1f}KB"
|
|
184
|
+
return f"{data_len / (1024 * 1024):.1f}MB"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Step 1: Strip noise tags + comments
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _strip_noise(parser: LexborHTMLParser) -> None:
|
|
193
|
+
"""Remove noise tag subtrees and comment nodes."""
|
|
194
|
+
to_remove = []
|
|
195
|
+
for node in _walk_nodes(parser.root):
|
|
196
|
+
if node.is_comment_node:
|
|
197
|
+
to_remove.append(node)
|
|
198
|
+
continue
|
|
199
|
+
if not node.is_element_node:
|
|
200
|
+
continue
|
|
201
|
+
if node.tag.lower() in NOISE_TAGS:
|
|
202
|
+
to_remove.append(node)
|
|
203
|
+
for node in to_remove:
|
|
204
|
+
node.remove()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Step 2: Simplify media → placeholders
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _simplify_media(parser: LexborHTMLParser) -> None:
|
|
213
|
+
"""Replace img/video/audio/source/track with text placeholders.
|
|
214
|
+
|
|
215
|
+
img → [img: <filename|size> <alt_first_10_chars>]
|
|
216
|
+
video → [video]
|
|
217
|
+
audio/source/track → [audio]
|
|
218
|
+
"""
|
|
219
|
+
for node in _walk_nodes(parser.root):
|
|
220
|
+
if not node.is_element_node:
|
|
221
|
+
continue
|
|
222
|
+
tag = node.tag.lower()
|
|
223
|
+
if tag == "img":
|
|
224
|
+
attrs = node.attributes
|
|
225
|
+
alt = (attrs.get("alt") or "").strip()
|
|
226
|
+
src = attrs.get("src", "")
|
|
227
|
+
if src.startswith("data:"):
|
|
228
|
+
# base64: show human-readable size
|
|
229
|
+
# data:image/png;base64,XXXX → estimate decoded size
|
|
230
|
+
_, _, data = src.partition(",")
|
|
231
|
+
size_str = _human_size(int(len(data) * 3 / 4)) if data else "0B"
|
|
232
|
+
identity = size_str
|
|
233
|
+
else:
|
|
234
|
+
identity = _normalize_url(src) if src else "unknown"
|
|
235
|
+
alt_text = alt[: settings.MEDIA_ALT_HEAD] if alt else ""
|
|
236
|
+
if alt_text:
|
|
237
|
+
node.replace_with(f"[img: {identity} {alt_text}]")
|
|
238
|
+
else:
|
|
239
|
+
node.replace_with(f"[img: {identity}]")
|
|
240
|
+
elif tag in ("video", "audio", "source", "track"):
|
|
241
|
+
node.replace_with("[video]" if tag == "video" else "[audio]")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Step 3: Clean attributes + URL normalization
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_data_attr(attr: str) -> bool:
|
|
250
|
+
"""Check if an attribute is a data-* attribute."""
|
|
251
|
+
return attr.startswith("data-")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _attrs_to_remove(attrs: Any) -> list[str]:
|
|
255
|
+
"""Compute which attribute names to delete based on mode and settings.
|
|
256
|
+
|
|
257
|
+
Blacklist mode (default): delete only REMOVE_ATTRS entries.
|
|
258
|
+
Whitelist mode: delete everything except KEEP_ATTRS + data-* (if enabled).
|
|
259
|
+
"""
|
|
260
|
+
if settings.ATTR_MODE == "whitelist":
|
|
261
|
+
return [
|
|
262
|
+
k
|
|
263
|
+
for k in attrs
|
|
264
|
+
if k.lower() not in settings.KEEP_ATTRS
|
|
265
|
+
and not (_is_data_attr(k) and settings.KEEP_DATA_ATTRS)
|
|
266
|
+
]
|
|
267
|
+
# Blacklist mode: only remove listed attrs
|
|
268
|
+
return [k for k in attrs if k.lower() in settings.REMOVE_ATTRS]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _clean_attributes_dom(parser: LexborHTMLParser) -> None:
|
|
272
|
+
"""Clean attributes using blacklist/whitelist mode.
|
|
273
|
+
|
|
274
|
+
Special handling for <a> tags:
|
|
275
|
+
- href empty → remove
|
|
276
|
+
- href="#" → unwrap if text content, else remove
|
|
277
|
+
- normal href → keep href (+ data-* attrs), remove others
|
|
278
|
+
|
|
279
|
+
For all other elements:
|
|
280
|
+
- blacklist mode: remove REMOVE_ATTRS, keep data-* if enabled
|
|
281
|
+
- whitelist mode: keep only KEEP_ATTRS + data-*
|
|
282
|
+
|
|
283
|
+
NOTE: Must use node.attrs (LexborAttributes) not node.attributes (copy dict).
|
|
284
|
+
"""
|
|
285
|
+
to_remove: list[Any] = []
|
|
286
|
+
to_unwrap: list[Any] = []
|
|
287
|
+
for node in _walk_nodes(parser.root):
|
|
288
|
+
if not node.is_element_node:
|
|
289
|
+
continue
|
|
290
|
+
tag = node.tag.lower()
|
|
291
|
+
# Use node.attrs (LexborAttributes) for in-place DOM modification
|
|
292
|
+
node_attrs = node.attrs
|
|
293
|
+
attrs_dict = node.attributes # copy, for reading values
|
|
294
|
+
|
|
295
|
+
if tag == "a":
|
|
296
|
+
href = attrs_dict.get("href", "")
|
|
297
|
+
if not href:
|
|
298
|
+
to_remove.append(node)
|
|
299
|
+
continue
|
|
300
|
+
if href.strip() == "#":
|
|
301
|
+
has_text = False
|
|
302
|
+
child = node.first_child
|
|
303
|
+
while child is not None:
|
|
304
|
+
if child.is_text_node:
|
|
305
|
+
t = child.text_lexbor()
|
|
306
|
+
if t and t.strip():
|
|
307
|
+
has_text = True
|
|
308
|
+
break
|
|
309
|
+
child = child.next
|
|
310
|
+
if has_text:
|
|
311
|
+
to_unwrap.append(node)
|
|
312
|
+
else:
|
|
313
|
+
to_remove.append(node)
|
|
314
|
+
continue
|
|
315
|
+
# Normal anchor: keep href + data-*, remove others
|
|
316
|
+
keys_to_del = [
|
|
317
|
+
k
|
|
318
|
+
for k in attrs_dict
|
|
319
|
+
if k.lower() != "href"
|
|
320
|
+
and not (_is_data_attr(k) and settings.KEEP_DATA_ATTRS)
|
|
321
|
+
]
|
|
322
|
+
for k in keys_to_del:
|
|
323
|
+
del node_attrs[k]
|
|
324
|
+
# Normalize href via attrs proxy
|
|
325
|
+
node_attrs["href"] = _normalize_url(href)
|
|
326
|
+
elif tag == "script":
|
|
327
|
+
# Script: keep src + type + data-*, normalize src
|
|
328
|
+
keys_to_del = [
|
|
329
|
+
k
|
|
330
|
+
for k in attrs_dict
|
|
331
|
+
if k.lower() not in ("src", "type")
|
|
332
|
+
and not (_is_data_attr(k) and settings.KEEP_DATA_ATTRS)
|
|
333
|
+
]
|
|
334
|
+
for k in keys_to_del:
|
|
335
|
+
del node_attrs[k]
|
|
336
|
+
if "src" in attrs_dict:
|
|
337
|
+
node_attrs["src"] = _normalize_url(attrs_dict["src"])
|
|
338
|
+
else:
|
|
339
|
+
# General elements: apply attribute mode
|
|
340
|
+
keys_to_del = _attrs_to_remove(attrs_dict)
|
|
341
|
+
for k in keys_to_del:
|
|
342
|
+
del node_attrs[k]
|
|
343
|
+
for node in to_remove:
|
|
344
|
+
node.remove()
|
|
345
|
+
for node in to_unwrap:
|
|
346
|
+
node.unwrap()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
# Step 6: Unwrap bare wrapper tags
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
_MAX_UNWRAP_ROUNDS = 100
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _is_bare(node: Any) -> bool:
|
|
357
|
+
"""Check if node has no meaningful attributes after filtering."""
|
|
358
|
+
if not node.attributes:
|
|
359
|
+
return True
|
|
360
|
+
# Has attributes only if something remains after attribute cleaning
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _has_structure_descendant(node: Any) -> bool:
|
|
365
|
+
"""Check if direct children contain structural tags (ul/ol/table/tr/td etc.)."""
|
|
366
|
+
child = node.first_child
|
|
367
|
+
while child is not None:
|
|
368
|
+
if not child.is_text_node and child.tag.lower() in STRUCTURE_TAGS:
|
|
369
|
+
return True
|
|
370
|
+
child = child.next
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _unwrap_bare_tags(parser: LexborHTMLParser) -> None:
|
|
375
|
+
"""Remove bare wrapper tags (no attributes), keeping children.
|
|
376
|
+
|
|
377
|
+
Protection rules:
|
|
378
|
+
- KEEP_TAGS never unwrapped
|
|
379
|
+
- Parent of structural tags never unwrapped
|
|
380
|
+
"""
|
|
381
|
+
for _ in range(_MAX_UNWRAP_ROUNDS):
|
|
382
|
+
to_unwrap: list[Any] = []
|
|
383
|
+
for node in _walk_nodes(parser.root):
|
|
384
|
+
if not node.is_element_node:
|
|
385
|
+
continue
|
|
386
|
+
tag = node.tag.lower()
|
|
387
|
+
if tag in KEEP_TAGS:
|
|
388
|
+
continue
|
|
389
|
+
if not _is_bare(node):
|
|
390
|
+
continue
|
|
391
|
+
if _has_structure_descendant(node):
|
|
392
|
+
continue
|
|
393
|
+
to_unwrap.append(node)
|
|
394
|
+
if not to_unwrap:
|
|
395
|
+
break
|
|
396
|
+
# Process leaf-upward to avoid parent-child conflicts
|
|
397
|
+
for node in reversed(to_unwrap):
|
|
398
|
+
if node.first_child is None:
|
|
399
|
+
node.remove()
|
|
400
|
+
else:
|
|
401
|
+
node.unwrap()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
# Step 5: Truncate text nodes (regular + script inline)
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def shorten_text(text: str, head: int, tail: int) -> str:
|
|
410
|
+
"""Truncate text by keeping head and tail units, inserting a placeholder.
|
|
411
|
+
|
|
412
|
+
Units: Chinese chars, English words, date/time patterns, numbers.
|
|
413
|
+
"""
|
|
414
|
+
if head <= 0 and tail <= 0:
|
|
415
|
+
return text
|
|
416
|
+
matches = list(_UNIT_RE.finditer(text))
|
|
417
|
+
total = len(matches)
|
|
418
|
+
if total <= head + tail:
|
|
419
|
+
return text
|
|
420
|
+
head_end = matches[head - 1].end() if head > 0 else 0
|
|
421
|
+
tail_start = matches[-tail].start() if tail > 0 else len(text)
|
|
422
|
+
hidden = total - head - tail
|
|
423
|
+
if hidden <= 6:
|
|
424
|
+
return text
|
|
425
|
+
return text[:head_end] + _SHORTENED_PLACEHOLDER.format(n=hidden) + text[tail_start:]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _truncate_text_nodes(parser: LexborHTMLParser) -> None:
|
|
429
|
+
"""Truncate all text nodes and script inline code."""
|
|
430
|
+
ops: list[tuple[Any, str | None]] = []
|
|
431
|
+
for node in _walk_nodes(parser.root):
|
|
432
|
+
if not node.is_text_node:
|
|
433
|
+
continue
|
|
434
|
+
parent = node.parent
|
|
435
|
+
if parent is None or parent.is_text_node:
|
|
436
|
+
continue
|
|
437
|
+
parent_tag = parent.tag.lower() if parent.is_element_node else ""
|
|
438
|
+
# Skip title tag text
|
|
439
|
+
if parent_tag == "title":
|
|
440
|
+
continue
|
|
441
|
+
original = node.text_lexbor()
|
|
442
|
+
if not original.strip():
|
|
443
|
+
ops.append((node, None))
|
|
444
|
+
continue
|
|
445
|
+
cleaned = _MULTI_WS_RE.sub(" ", original).strip()
|
|
446
|
+
# Script inline code uses SCRIPT_HEAD/TAIL, others use TEXT_HEAD/TAIL
|
|
447
|
+
if parent_tag == "script":
|
|
448
|
+
shortened = shorten_text(
|
|
449
|
+
cleaned, settings.SCRIPT_HEAD, settings.SCRIPT_TAIL
|
|
450
|
+
)
|
|
451
|
+
else:
|
|
452
|
+
shortened = shorten_text(cleaned, settings.TEXT_HEAD, settings.TEXT_TAIL)
|
|
453
|
+
if shortened != original:
|
|
454
|
+
ops.append((node, shortened))
|
|
455
|
+
for node, new_text in ops:
|
|
456
|
+
if new_text is None:
|
|
457
|
+
node.remove()
|
|
458
|
+
else:
|
|
459
|
+
node.replace_with(new_text)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
# Step 4: Truncate repeated list items
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _truncate_repeated_items(parser: LexborHTMLParser) -> None:
|
|
468
|
+
"""Truncate consecutive li/tr items exceeding threshold, keep head+tail, insert marker."""
|
|
469
|
+
containers: list[list[Any]] = []
|
|
470
|
+
for node in _walk_nodes(parser.root):
|
|
471
|
+
if not node.is_element_node:
|
|
472
|
+
continue
|
|
473
|
+
if node.tag.lower() not in ("ul", "ol", "table", "tbody", "thead", "tfoot"):
|
|
474
|
+
continue
|
|
475
|
+
items: list[Any] = []
|
|
476
|
+
child = node.first_child
|
|
477
|
+
while child is not None:
|
|
478
|
+
if child.is_element_node and child.tag.lower() in TRUNCATABLE_ITEM_TAGS:
|
|
479
|
+
items.append(child)
|
|
480
|
+
child = child.next
|
|
481
|
+
threshold = (
|
|
482
|
+
settings.LIST_TRUNCATE_KEEP_HEAD + settings.LIST_TRUNCATE_KEEP_TAIL + 1
|
|
483
|
+
)
|
|
484
|
+
if len(items) > threshold:
|
|
485
|
+
containers.append(items)
|
|
486
|
+
for items in containers:
|
|
487
|
+
total = len(items)
|
|
488
|
+
keep_head = settings.LIST_TRUNCATE_KEEP_HEAD
|
|
489
|
+
keep_tail = settings.LIST_TRUNCATE_KEEP_TAIL
|
|
490
|
+
to_remove = items[keep_head:-keep_tail] if keep_tail > 0 else items[keep_head:]
|
|
491
|
+
omitted = len(to_remove)
|
|
492
|
+
# Insert marker before first tail item
|
|
493
|
+
anchor = items[-keep_tail] if keep_tail > 0 else items[-1]
|
|
494
|
+
anchor.insert_before(f"[共{total}条,省略{omitted}条]")
|
|
495
|
+
for item in to_remove:
|
|
496
|
+
item.remove()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# Step 7: Whitespace normalization
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _clean_whitespace(html: str) -> str:
|
|
505
|
+
"""Collapse multi-char whitespace between and within tags."""
|
|
506
|
+
return re.sub(r">\s+<", "> <", html)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
# Pipeline assembly
|
|
511
|
+
# ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
_PIPELINE_STEPS = [
|
|
514
|
+
("strip_noise", _strip_noise),
|
|
515
|
+
("simplify_media", _simplify_media),
|
|
516
|
+
("clean_attributes", _clean_attributes_dom),
|
|
517
|
+
("truncate_lists", _truncate_repeated_items),
|
|
518
|
+
("truncate_text", _truncate_text_nodes),
|
|
519
|
+
("unwrap_bare_tags", _unwrap_bare_tags),
|
|
520
|
+
]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def simplify_html(html: str) -> dict[str, Any]:
|
|
524
|
+
"""Run the 7-step simplification pipeline.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
dict with keys:
|
|
528
|
+
- "html": simplified HTML string
|
|
529
|
+
- "stats": dict[str, dict[str, int]] mapping step name to
|
|
530
|
+
{char_before, char_after}
|
|
531
|
+
"""
|
|
532
|
+
stats: dict[str, dict[str, int]] = {}
|
|
533
|
+
parser = LexborHTMLParser(html)
|
|
534
|
+
|
|
535
|
+
for step_name, step_fn in _PIPELINE_STEPS:
|
|
536
|
+
char_before = len(parser.html)
|
|
537
|
+
step_fn(parser)
|
|
538
|
+
char_after = len(parser.html)
|
|
539
|
+
stats[step_name] = {"char_before": char_before, "char_after": char_after}
|
|
540
|
+
|
|
541
|
+
# Final whitespace cleanup (string-level)
|
|
542
|
+
ws_before = len(parser.html)
|
|
543
|
+
result_html = _clean_whitespace(parser.html)
|
|
544
|
+
ws_after = len(result_html)
|
|
545
|
+
stats["clean_whitespace"] = {"char_before": ws_before, "char_after": ws_after}
|
|
546
|
+
|
|
547
|
+
return {"html": result_html, "stats": stats}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: html-reader-llm
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: HTML simplification and intelligent extraction for LLM
|
|
5
|
+
Author-email: Clericpy <clericpy@gmail.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: minify-html>=0.18.1
|
|
10
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
11
|
+
Requires-Dist: selectolax>=0.4.7
|
|
12
|
+
Requires-Dist: trafilatura>=2.2.0
|
|
13
|
+
|
|
14
|
+
# html-reader-llm
|
|
15
|
+
|
|
16
|
+
HTML simplification and intelligent extraction for LLM.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- HTML simplification for LLM consumption
|
|
21
|
+
- Render detection (HTTP vs Chrome)
|
|
22
|
+
- LLM-powered page classification and CSS selector generation
|
|
23
|
+
- CSS selector validation
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install html-reader-llm
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Simplify HTML
|
|
35
|
+
html-reader-llm simplify < page.html
|
|
36
|
+
|
|
37
|
+
# Detect if browser rendering is needed
|
|
38
|
+
html-reader-llm detect https://example.com
|
|
39
|
+
|
|
40
|
+
# Full analysis with LLM
|
|
41
|
+
html-reader-llm analyze https://example.com
|
|
42
|
+
|
|
43
|
+
# Fetch HTML
|
|
44
|
+
html-reader-llm fetch https://example.com
|
|
45
|
+
|
|
46
|
+
# Validate CSS selectors
|
|
47
|
+
html-reader-llm validate https://example.com --rules-file selectors.json
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
html_reader_llm/__init__.py,sha256=nvxvpTlcArqeCGCqqTa9571dAJSvJtGHEaz2md3rqd4,1210
|
|
2
|
+
html_reader_llm/browser.py,sha256=Kw7d70YTyL0m6vK6slO4icYYu5iJqVYcYndmLA4_NoE,4717
|
|
3
|
+
html_reader_llm/cli.py,sha256=oOQJU3wrOTZ0OQUMypchiBa03k-0A5_QuuqAHfGudA0,19957
|
|
4
|
+
html_reader_llm/llm.py,sha256=vWD6ZvNBSzkjVF6SaKOQOfYVc4lozIPDKgTJTCqENOY,15824
|
|
5
|
+
html_reader_llm/render_detect.py,sha256=XTPkg8jsB9py31LkJ9_H2pi1BxjCpPTVY4Q8QL3fRwE,12607
|
|
6
|
+
html_reader_llm/selector_validator.py,sha256=sZsEW9Rfx_ei-xZOqmoY4miojehjk-DHfbex5U52n94,5495
|
|
7
|
+
html_reader_llm/settings.py,sha256=jV3jS-u1wtgSirLDqI_DVraet12Rqgt0LoKtOzJcLtE,2525
|
|
8
|
+
html_reader_llm/simplify.py,sha256=XuP3KP_JeXvvsigWo9WoGLj2do7GLLKB7F3TXNIvzy8,17345
|
|
9
|
+
html_reader_llm-0.0.1.dist-info/entry_points.txt,sha256=eA-OAGTqIjfT4ATIHu5dSj6aJn6Bb3cfyUL1PR7gSEo,60
|
|
10
|
+
html_reader_llm-0.0.1.dist-info/licenses/LICENSE,sha256=2wBhZrpJjfa6xenTNHJNPMLlhljJ3R6p8zhA3SGNTfo,1086
|
|
11
|
+
html_reader_llm-0.0.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
12
|
+
html_reader_llm-0.0.1.dist-info/METADATA,sha256=1Ql68mmnTjL1ZMbm8Z6ivM6LnSqp-VMpLJfRzpRx97E,1107
|
|
13
|
+
html_reader_llm-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ClericPy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|