webreader 2.3.8__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.
webreader/core.py ADDED
@@ -0,0 +1,501 @@
1
+ """Tree-based HTML parsing engine built on top of the parser package."""
2
+
3
+ import codecs
4
+ import hashlib
5
+ import os
6
+ import re
7
+ from html import escape as _escape
8
+ from html.parser import HTMLParser as _BaseHTMLParser
9
+ from typing import Any, Dict, Iterator, List, Optional, Union
10
+
11
+ from pafer.cache import LRUCache
12
+ from pafer.config import (
13
+ Config,
14
+ PathLike,
15
+ load_config as _load_config,
16
+ save_config as _save_config,
17
+ )
18
+ from pafer.exceptions import ConfigLoadError, HTMLPaferError
19
+ from pafer.utils import extract_doctype, minify_html
20
+
21
+ VOID_ELEMENTS = {
22
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
23
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
24
+ }
25
+
26
+ BLOCK_TAGS = {
27
+ 'address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'dd',
28
+ 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
29
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li',
30
+ 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'tbody', 'thead',
31
+ 'tfoot', 'tr', 'ul',
32
+ }
33
+
34
+ ROOT_TAG = ':root'
35
+ COMMENT_TAG = ':comment'
36
+
37
+ PRESERVE_WHITESPACE_TAGS = {'pre', 'textarea'}
38
+
39
+
40
+ def detect_encoding(data: bytes, default: str = 'utf-8') -> str:
41
+ """Detect the encoding of raw HTML: BOM sniff, then <meta charset>."""
42
+ if data.startswith(codecs.BOM_UTF8):
43
+ return 'utf-8-sig'
44
+ if data.startswith((codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)):
45
+ return 'utf-32'
46
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
47
+ return 'utf-16'
48
+ head = data[:2048].decode('latin-1', errors='ignore')
49
+ match = re.search(
50
+ r'<meta[^>]+charset\s*=\s*["\']?\s*([\w.:+-]+)', head, re.IGNORECASE
51
+ )
52
+ if match:
53
+ try:
54
+ codecs.lookup(match.group(1))
55
+ return match.group(1)
56
+ except LookupError:
57
+ pass
58
+ return default
59
+
60
+
61
+ class Node:
62
+ """A single element or comment node in the parsed HTML tree."""
63
+
64
+ def __init__(self, tag: str, attrs: Optional[Dict[str, str]] = None,
65
+ parent: Optional['Node'] = None) -> None:
66
+ self.tag = tag
67
+ self.attrs: Dict[str, str] = dict(attrs or {})
68
+ self.children: List[Any] = []
69
+ self.parent: Optional[Node] = parent
70
+
71
+ @property
72
+ def is_comment(self) -> bool:
73
+ return self.tag == COMMENT_TAG
74
+
75
+ def get_attr(self, name: str, default: Optional[str] = None) -> Optional[str]:
76
+ return self.attrs.get(name, default)
77
+
78
+ def has_class(self, name: str) -> bool:
79
+ return name in (self.get_attr('class') or '').split()
80
+
81
+ def set_attr(self, name: str, value: str) -> 'Node':
82
+ self.attrs[name] = value
83
+ return self
84
+
85
+ def remove_attr(self, name: str) -> 'Node':
86
+ self.attrs.pop(name, None)
87
+ return self
88
+
89
+ def append_child(self, node: 'Node') -> 'Node':
90
+ if node.parent is not None:
91
+ node.parent.children.remove(node)
92
+ self.children.append(node)
93
+ node.parent = self
94
+ return self
95
+
96
+ def remove(self) -> 'Node':
97
+ if self.parent is not None:
98
+ self.parent.children.remove(self)
99
+ self.parent = None
100
+ return self
101
+
102
+ def replace_with(self, *nodes: 'Node') -> 'Node':
103
+ if self.parent is None:
104
+ raise PaferError('cannot replace a detached node')
105
+ siblings = self.parent.children
106
+ index = siblings.index(self)
107
+ replacements = list(nodes)
108
+ for node in replacements:
109
+ if node.parent is not None:
110
+ node.parent.children.remove(node)
111
+ siblings[index:index + 1] = replacements
112
+ for node in replacements:
113
+ node.parent = self.parent
114
+ self.parent = None
115
+ return self
116
+
117
+ def unwrap(self) -> 'Node':
118
+ if self.parent is None:
119
+ raise PaferError('cannot unwrap a detached node')
120
+ siblings = self.parent.children
121
+ index = siblings.index(self)
122
+ children = list(self.children)
123
+ siblings[index:index + 1] = children
124
+ for child in children:
125
+ if isinstance(child, Node):
126
+ child.parent = self.parent
127
+ self.children = []
128
+ self.parent = None
129
+ return self
130
+
131
+ def child_elements(self) -> List['Node']:
132
+ return [
133
+ child
134
+ for child in self.children
135
+ if isinstance(child, Node) and not child.is_comment
136
+ ]
137
+
138
+ def text_content(self) -> str:
139
+ parts: List[str] = []
140
+ for child in self.children:
141
+ if isinstance(child, str):
142
+ parts.append(child)
143
+ else:
144
+ parts.append(child.text_content())
145
+ return ''.join(parts)
146
+
147
+ def iter_elements(self) -> Iterator['Node']:
148
+ for child in self.children:
149
+ if isinstance(child, Node) and not child.is_comment:
150
+ yield child
151
+ yield from child.iter_elements()
152
+
153
+ def to_html(self) -> str:
154
+ if self.is_comment:
155
+ return f'<!--{self.text_content()}-->'
156
+ attrs = ''.join(
157
+ f' {name}' if value == '' else f' {name}="{_escape(value, quote=True)}"'
158
+ for name, value in self.attrs.items()
159
+ )
160
+ if self.tag in VOID_ELEMENTS:
161
+ return f'<{self.tag}{attrs}>'
162
+ inner = ''.join(
163
+ _escape(child, quote=False) if isinstance(child, str) else child.to_html()
164
+ for child in self.children
165
+ )
166
+ return f'<{self.tag}{attrs}>{inner}</{self.tag}>'
167
+
168
+ def to_dict(self) -> Dict[str, Any]:
169
+ return {
170
+ 'tag': self.tag,
171
+ 'attrs': dict(self.attrs),
172
+ 'text': self.text_content().strip(),
173
+ 'html': self.to_html(),
174
+ }
175
+
176
+ def __repr__(self) -> str:
177
+ return f'<Node {self.tag}>'
178
+
179
+
180
+ class _TreeBuilder(_BaseHTMLParser):
181
+ """Builds a Node tree from HTML using the stdlib tokenizer."""
182
+
183
+ def __init__(self, config: Config) -> None:
184
+ super().__init__(convert_charrefs=True)
185
+ self.config = config
186
+ self.root = Node(ROOT_TAG)
187
+ self._stack: List[Node] = [self.root]
188
+ self.comments: List[str] = []
189
+ self.errors: List[str] = []
190
+ self._max_depth = int(config.get('max_depth', 100) or 100)
191
+ self._preserve_comments = bool(config.get('preserve_comments', False))
192
+ self._strip_whitespace = bool(config.get('strip_whitespace', True))
193
+ self._strict = bool(config.get('strict_mode', False))
194
+
195
+ def _current(self) -> Node:
196
+ return self._stack[-1]
197
+
198
+ def _attach(self, node: Node) -> None:
199
+ parent = self._current()
200
+ if len(self._stack) <= self._max_depth:
201
+ parent.children.append(node)
202
+ node.parent = parent
203
+ elif self._strict:
204
+ self.errors.append(
205
+ f'<{node.tag}> exceeds max_depth={self._max_depth}'
206
+ )
207
+ self._stack.append(node)
208
+
209
+ def handle_starttag(self, tag, attrs) -> None:
210
+ node = Node(tag, {k: (v if v is not None else '') for k, v in attrs})
211
+ self._attach(node)
212
+ if tag in VOID_ELEMENTS:
213
+ self._stack.pop()
214
+
215
+ def handle_startendtag(self, tag, attrs) -> None:
216
+ node = Node(tag, {k: (v if v is not None else '') for k, v in attrs})
217
+ if len(self._stack) <= self._max_depth:
218
+ parent = self._current()
219
+ parent.children.append(node)
220
+ node.parent = parent
221
+
222
+ def handle_endtag(self, tag) -> None:
223
+ if tag in VOID_ELEMENTS:
224
+ return
225
+ for i in range(len(self._stack) - 1, 0, -1):
226
+ if self._stack[i].tag == tag:
227
+ if len(self._stack) - 1 > i:
228
+ unclosed = [n.tag for n in self._stack[i + 1:]]
229
+ self.errors.append(
230
+ f'unclosed tag(s) <{"> <".join(unclosed)}> before </{tag}>'
231
+ )
232
+ del self._stack[i:]
233
+ return
234
+ self.errors.append(f'closing tag </{tag}> has no matching open tag')
235
+
236
+ def handle_data(self, data) -> None:
237
+ if not data:
238
+ return
239
+ if self._strip_whitespace and not any(
240
+ node.tag in PRESERVE_WHITESPACE_TAGS for node in self._stack
241
+ ):
242
+ data = re.sub(r'\s+', ' ', data)
243
+ self._current().children.append(data)
244
+
245
+ def handle_comment(self, data) -> None:
246
+ self.comments.append(data)
247
+ if self._preserve_comments:
248
+ comment = Node(COMMENT_TAG)
249
+ comment.children.append(data)
250
+ if len(self._stack) <= self._max_depth:
251
+ parent = self._current()
252
+ parent.children.append(comment)
253
+ comment.parent = parent
254
+
255
+ def close(self) -> None:
256
+ super().close()
257
+ if len(self._stack) > 1:
258
+ unclosed = [n.tag for n in self._stack[1:]]
259
+ self.errors.append(
260
+ f'unclosed tag(s) at end of input: <{"> <".join(unclosed)}>'
261
+ )
262
+
263
+
264
+ class ParsedDocument:
265
+ """Structured, lazily-enriched result of parsing an HTML document."""
266
+
267
+ def __init__(self, doctype: Optional[str], root: Node,
268
+ comments: Optional[List[str]] = None,
269
+ config: Optional[Config] = None,
270
+ errors: Optional[List[str]] = None) -> None:
271
+ self.doctype = doctype
272
+ self.root = root
273
+ self.comments = comments or []
274
+ self.errors = errors or []
275
+ self.config = config or Config()
276
+ self._metadata: Optional[Dict[str, Any]] = None
277
+ self._links: Optional[List[Dict[str, Any]]] = None
278
+ self._tables: Optional[List[Dict[str, Any]]] = None
279
+ self._text: Optional[str] = None
280
+ self._images: Optional[List[Dict[str, Any]]] = None
281
+ self._forms: Optional[List[Dict[str, Any]]] = None
282
+ self._headings: Optional[Dict[str, Any]] = None
283
+ self._scripts: Optional[List[Dict[str, Any]]] = None
284
+ self._stylesheets: Optional[List[Dict[str, Any]]] = None
285
+ self._markdown: Optional[str] = None
286
+
287
+ def metadata(self) -> Dict[str, Any]:
288
+ from .extractors import extract_metadata
289
+ if self._metadata is None:
290
+ self._metadata = extract_metadata(self)
291
+ return self._metadata
292
+
293
+ def links(self, base_url: Optional[str] = None) -> List[Dict[str, Any]]:
294
+ from .extractors import extract_links
295
+ if base_url is None and self._links is not None:
296
+ return self._links
297
+ result = extract_links(self, base_url=base_url)
298
+ if base_url is None:
299
+ self._links = result
300
+ return result
301
+
302
+ def tables(self) -> List[Dict[str, Any]]:
303
+ from .extractors import extract_tables
304
+ if self._tables is None:
305
+ self._tables = extract_tables(self)
306
+ return self._tables
307
+
308
+ def text(self) -> str:
309
+ from .extractors import extract_text
310
+ if self._text is None:
311
+ self._text = extract_text(self)
312
+ return self._text
313
+
314
+ def find_all(self, selector: str) -> List[Node]:
315
+ from .selectors import find_all
316
+ return find_all(self.root, selector)
317
+
318
+ def find(self, selector: str) -> Optional[Node]:
319
+ from .selectors import find
320
+ return find(self.root, selector)
321
+
322
+ def images(self) -> List[Dict[str, Any]]:
323
+ from .extractors import extract_images
324
+ if self._images is None:
325
+ self._images = extract_images(self)
326
+ return self._images
327
+
328
+ def forms(self) -> List[Dict[str, Any]]:
329
+ from .extractors import extract_forms
330
+ if self._forms is None:
331
+ self._forms = extract_forms(self)
332
+ return self._forms
333
+
334
+ def headings(self) -> Dict[str, Any]:
335
+ from .extractors import extract_headings
336
+ if self._headings is None:
337
+ self._headings = extract_headings(self)
338
+ return self._headings
339
+
340
+ def scripts(self) -> List[Dict[str, Any]]:
341
+ from .extractors import extract_scripts
342
+ if self._scripts is None:
343
+ self._scripts = extract_scripts(self)
344
+ return self._scripts
345
+
346
+ def stylesheets(self) -> List[Dict[str, Any]]:
347
+ from .extractors import extract_stylesheets
348
+ if self._stylesheets is None:
349
+ self._stylesheets = extract_stylesheets(self)
350
+ return self._stylesheets
351
+
352
+ def to_markdown(self) -> str:
353
+ from .markdown import document_to_markdown
354
+ if self._markdown is None:
355
+ self._markdown = document_to_markdown(self)
356
+ return self._markdown
357
+
358
+ def to_html(self) -> str:
359
+ return ''.join(
360
+ _escape(child, quote=False) if isinstance(child, str) else child.to_html()
361
+ for child in self.root.children
362
+ )
363
+
364
+ def stats(self) -> Dict[str, int]:
365
+ element_count = 0
366
+ max_depth = 0
367
+
368
+ def walk(node: Node, depth: int) -> None:
369
+ nonlocal element_count, max_depth
370
+ for child in node.children:
371
+ if isinstance(child, Node) and not child.is_comment:
372
+ element_count += 1
373
+ if depth + 1 > max_depth:
374
+ max_depth = depth + 1
375
+ walk(child, depth + 1)
376
+
377
+ walk(self.root, 0)
378
+ return {
379
+ 'elements': element_count,
380
+ 'comments': len(self.comments),
381
+ 'max_depth': max_depth,
382
+ }
383
+
384
+ def to_dict(self) -> Dict[str, Any]:
385
+ return {
386
+ 'doctype': self.doctype,
387
+ 'stats': self.stats(),
388
+ 'metadata': self.metadata(),
389
+ 'links': self.links(),
390
+ 'tables': self.tables(),
391
+ 'text': self.text(),
392
+ 'comments': list(self.comments),
393
+ 'errors': list(self.errors),
394
+ }
395
+
396
+ def __repr__(self) -> str:
397
+ return f'<ParsedDocument {self.stats()["elements"]} elements>'
398
+
399
+
400
+ class Engine:
401
+ """High-level HTML processing engine configured by a pafer Config."""
402
+
403
+ def __init__(self, config: Optional[Config] = None, use_cache: bool = True) -> None:
404
+ self.config = config or Config()
405
+ self.cache: Optional[LRUCache] = None
406
+ if use_cache:
407
+ self.cache = LRUCache(max_size=int(self.config.get('cache_size', 50) or 50))
408
+
409
+ def parse(self, html: Union[str, bytes]) -> ParsedDocument:
410
+ """Parse HTML (str or bytes) into a ParsedDocument."""
411
+ if isinstance(html, bytes):
412
+ encoding = detect_encoding(
413
+ html,
414
+ default=str(self.config.get('encoding', 'utf-8') or 'utf-8'),
415
+ )
416
+ html = html.decode(encoding, errors='replace')
417
+ key = hashlib.sha256(html.encode('utf-8', errors='replace')).hexdigest()
418
+ if self.cache is not None:
419
+ cached = self.cache.get(key)
420
+ if cached is not None:
421
+ return cached
422
+ self._check_size(html)
423
+ builder = _TreeBuilder(self.config)
424
+ try:
425
+ builder.feed(html)
426
+ builder.close()
427
+ except PaferError:
428
+ raise
429
+ except Exception as exc:
430
+ raise PaferError(f'failed to parse HTML: {exc}') from exc
431
+ if bool(self.config.get('strict_mode', False)) and builder.errors:
432
+ summary = '; '.join(builder.errors[:5])
433
+ raise PaferError(f'malformed HTML (strict mode): {summary}')
434
+ doc = ParsedDocument(
435
+ doctype=extract_doctype(html),
436
+ root=builder.root,
437
+ comments=builder.comments,
438
+ config=self.config,
439
+ errors=builder.errors,
440
+ )
441
+ if self.cache is not None:
442
+ self.cache.set(key, doc)
443
+ return doc
444
+
445
+ def _check_size(self, html: str) -> None:
446
+ cap = self.config.get('max_file_size_mb', 10)
447
+ if not cap:
448
+ return
449
+ size_mb = len(html.encode('utf-8', errors='replace')) / (1024 * 1024)
450
+ if size_mb > float(cap):
451
+ raise PaferError(
452
+ f'input of {size_mb:.2f} MB exceeds max_file_size_mb={cap}'
453
+ )
454
+
455
+ def minify(self, html: str) -> str:
456
+ """Minify HTML honoring remove_comments from the configuration."""
457
+ return minify_html(html, remove_comments=bool(self.config.get('remove_comments', True)))
458
+
459
+
460
+ def parse_raw(html: Union[str, bytes],
461
+ config: Optional[Config] = None) -> ParsedDocument:
462
+ """Parse with whitespace preserved; used by pretty and markdown."""
463
+ cfg = (config or Config()).update({'strip_whitespace': False})
464
+ return Engine(cfg, use_cache=False).parse(html)
465
+
466
+ def get_config():
467
+ """Return the active configuration object."""
468
+ return config
469
+
470
+ def get_engine() -> Engine:
471
+ """Return the active parsing engine."""
472
+ return _engine
473
+
474
+ def load_config(path: Optional[PathLike] = None) -> Dict[str, Any]:
475
+ """Load user config."""
476
+ global config, _engine
477
+ try:
478
+ cfg = _load_config(path)
479
+ except ConfigLoadError:
480
+ cfg = {}
481
+ try:
482
+ _save_config(cfg)
483
+ except (ConfigLoadError, OSError):
484
+ pass
485
+ config = cfg
486
+ _engine = Engine(cfg)
487
+ return cfg
488
+
489
+ def save_config(cfg) -> None:
490
+ try:
491
+ _save_config(cfg)
492
+ except OSError:
493
+ pass
494
+
495
+
496
+ try:
497
+ config = _load_config(None)
498
+ except Exception:
499
+ config = Config()
500
+
501
+ _engine = Engine(config)
Binary file