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/__init__.py +165 -0
- webreader/__main__.py +6 -0
- webreader/cli.py +166 -0
- webreader/core.py +501 -0
- webreader/default.config +0 -0
- webreader/extractors.py +447 -0
- webreader/formatter.py +89 -0
- webreader/markdown.py +237 -0
- webreader/sanitizer.py +117 -0
- webreader/selectors.py +462 -0
- webreader-2.3.8.dist-info/METADATA +188 -0
- webreader-2.3.8.dist-info/RECORD +15 -0
- webreader-2.3.8.dist-info/WHEEL +5 -0
- webreader-2.3.8.dist-info/licenses/LICENSE +21 -0
- webreader-2.3.8.dist-info/top_level.txt +1 -0
webreader/extractors.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Content extractors: metadata, links/assets, tables and readable text."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
from .core import BLOCK_TAGS, Node, ParsedDocument
|
|
9
|
+
|
|
10
|
+
_SKIP_TEXT_TAGS = {'script', 'style', 'noscript', 'template', 'head'}
|
|
11
|
+
_BOILERPLATE_TAGS = {'nav', 'footer'}
|
|
12
|
+
_BOILERPLATE_NAME_RE = re.compile(
|
|
13
|
+
r'\b(nav|navbar|menu|sidebar|header|footer|banner|advert|ads?|promo|breadcrumb)\b',
|
|
14
|
+
re.IGNORECASE,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_URL_ATTRS = {
|
|
18
|
+
'a': 'href',
|
|
19
|
+
'img': 'src',
|
|
20
|
+
'script': 'src',
|
|
21
|
+
'iframe': 'src',
|
|
22
|
+
'source': 'src',
|
|
23
|
+
'embed': 'src',
|
|
24
|
+
'track': 'src',
|
|
25
|
+
'link': 'href',
|
|
26
|
+
'area': 'href',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_SIMPLE_META_KEYS = (
|
|
30
|
+
'description', 'author', 'keywords', 'viewport', 'generator', 'robots',
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_HEADING_TAGS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
|
|
34
|
+
|
|
35
|
+
_WORDS_PER_MINUTE = 200
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_srcset(value: str) -> List[Tuple[str, str]]:
|
|
39
|
+
"""Split a srcset attribute into (url, descriptor) candidates."""
|
|
40
|
+
candidates: List[Tuple[str, str]] = []
|
|
41
|
+
for part in value.split(','):
|
|
42
|
+
tokens = part.strip().split()
|
|
43
|
+
if tokens:
|
|
44
|
+
candidates.append((tokens[0], tokens[1] if len(tokens) > 1 else ''))
|
|
45
|
+
return candidates
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _first(root: Node, tag: str) -> Optional[Node]:
|
|
49
|
+
for element in root.iter_elements():
|
|
50
|
+
if element.tag == tag:
|
|
51
|
+
return element
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _meta_entries(root: Node) -> Iterator[Tuple[str, str]]:
|
|
56
|
+
for element in root.iter_elements():
|
|
57
|
+
if element.tag == 'meta':
|
|
58
|
+
key = element.get_attr('property') or element.get_attr('name')
|
|
59
|
+
if key:
|
|
60
|
+
yield key.strip().lower(), (element.get_attr('content') or '')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_metadata(doc: ParsedDocument) -> Dict[str, Any]:
|
|
64
|
+
"""Extract title, description, Open Graph, Twitter card, canonical URL,
|
|
65
|
+
lang, charset and JSON-LD blocks from a parsed document."""
|
|
66
|
+
meta: Dict[str, Any] = {}
|
|
67
|
+
open_graph: Dict[str, str] = {}
|
|
68
|
+
twitter: Dict[str, str] = {}
|
|
69
|
+
|
|
70
|
+
for key, content in _meta_entries(doc.root):
|
|
71
|
+
if key.startswith('og:'):
|
|
72
|
+
open_graph[key[3:]] = content
|
|
73
|
+
elif key.startswith('twitter:'):
|
|
74
|
+
twitter[key[8:]] = content
|
|
75
|
+
elif key in _SIMPLE_META_KEYS:
|
|
76
|
+
meta[key] = content
|
|
77
|
+
|
|
78
|
+
title = _first(doc.root, 'title')
|
|
79
|
+
if title is not None:
|
|
80
|
+
meta['title'] = title.text_content().strip()
|
|
81
|
+
|
|
82
|
+
html_el = _first(doc.root, 'html')
|
|
83
|
+
if html_el is not None and html_el.get_attr('lang'):
|
|
84
|
+
meta['lang'] = html_el.get_attr('lang')
|
|
85
|
+
|
|
86
|
+
for element in doc.root.iter_elements():
|
|
87
|
+
if element.tag == 'link':
|
|
88
|
+
rel = (element.get_attr('rel') or '').lower()
|
|
89
|
+
if rel == 'canonical' and element.get_attr('href'):
|
|
90
|
+
meta['canonical'] = element.get_attr('href')
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
for element in doc.root.iter_elements():
|
|
94
|
+
if element.tag != 'meta':
|
|
95
|
+
continue
|
|
96
|
+
charset = element.get_attr('charset')
|
|
97
|
+
if charset:
|
|
98
|
+
meta['charset'] = charset.lower()
|
|
99
|
+
break
|
|
100
|
+
if (element.get_attr('http-equiv') or '').lower() == 'content-type':
|
|
101
|
+
match = re.search(
|
|
102
|
+
r'charset=([\w-]+)', element.get_attr('content') or '', re.IGNORECASE
|
|
103
|
+
)
|
|
104
|
+
if match:
|
|
105
|
+
meta['charset'] = match.group(1).lower()
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
json_ld: List[Any] = []
|
|
109
|
+
for element in doc.root.iter_elements():
|
|
110
|
+
if element.tag == 'script':
|
|
111
|
+
type_ = (element.get_attr('type') or '').lower()
|
|
112
|
+
if type_ == 'application/ld+json':
|
|
113
|
+
raw = element.text_content().strip()
|
|
114
|
+
try:
|
|
115
|
+
json_ld.append(json.loads(raw))
|
|
116
|
+
except ValueError:
|
|
117
|
+
json_ld.append(raw)
|
|
118
|
+
if json_ld:
|
|
119
|
+
meta['json_ld'] = json_ld
|
|
120
|
+
if open_graph:
|
|
121
|
+
meta['open_graph'] = open_graph
|
|
122
|
+
if twitter:
|
|
123
|
+
meta['twitter'] = twitter
|
|
124
|
+
return meta
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def extract_links(doc: ParsedDocument,
|
|
128
|
+
base_url: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
129
|
+
"""Extract hyperlinks and asset references, optionally absolutized
|
|
130
|
+
against base_url."""
|
|
131
|
+
links: List[Dict[str, Any]] = []
|
|
132
|
+
|
|
133
|
+
def add(element: Node, url: str, kind: str) -> None:
|
|
134
|
+
entry: Dict[str, Any] = {
|
|
135
|
+
'tag': element.tag,
|
|
136
|
+
'kind': kind,
|
|
137
|
+
'url': urljoin(base_url, url) if base_url else url,
|
|
138
|
+
}
|
|
139
|
+
if element.tag == 'a':
|
|
140
|
+
entry['text'] = element.text_content().strip()[:200]
|
|
141
|
+
if element.tag == 'img':
|
|
142
|
+
entry['alt'] = element.get_attr('alt') or ''
|
|
143
|
+
if element.tag == 'link':
|
|
144
|
+
entry['rel'] = (element.get_attr('rel') or '').lower()
|
|
145
|
+
links.append(entry)
|
|
146
|
+
|
|
147
|
+
for element in doc.root.iter_elements():
|
|
148
|
+
attr = _URL_ATTRS.get(element.tag)
|
|
149
|
+
if attr:
|
|
150
|
+
url = element.get_attr(attr)
|
|
151
|
+
if url:
|
|
152
|
+
add(element, url, 'href' if attr == 'href' else 'src')
|
|
153
|
+
srcset = element.get_attr('srcset')
|
|
154
|
+
if srcset:
|
|
155
|
+
for url, _ in _parse_srcset(srcset):
|
|
156
|
+
add(element, url, 'srcset')
|
|
157
|
+
return links
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _int_attr(cell: Node, name: str) -> int:
|
|
161
|
+
try:
|
|
162
|
+
return max(1, int(cell.get_attr(name) or 1))
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
return 1
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _in_thead(row: Node) -> bool:
|
|
168
|
+
node = row.parent
|
|
169
|
+
while node is not None:
|
|
170
|
+
if node.tag == 'thead':
|
|
171
|
+
return True
|
|
172
|
+
if node.tag == 'table':
|
|
173
|
+
return False
|
|
174
|
+
node = node.parent
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _table_rows(table: Node) -> List[Node]:
|
|
179
|
+
rows: List[Node] = []
|
|
180
|
+
|
|
181
|
+
def walk(node: Node) -> None:
|
|
182
|
+
for child in node.children:
|
|
183
|
+
if not isinstance(child, Node) or child.is_comment:
|
|
184
|
+
continue
|
|
185
|
+
if child.tag == 'tr':
|
|
186
|
+
rows.append(child)
|
|
187
|
+
elif child.tag in ('thead', 'tbody', 'tfoot'):
|
|
188
|
+
walk(child)
|
|
189
|
+
|
|
190
|
+
walk(table)
|
|
191
|
+
return rows
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _row_cells(row: Node) -> List[Node]:
|
|
195
|
+
return [child for child in row.child_elements() if child.tag in ('td', 'th')]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_table(table: Node) -> Dict[str, Any]:
|
|
199
|
+
caption = ''
|
|
200
|
+
for child in table.child_elements():
|
|
201
|
+
if child.tag == 'caption':
|
|
202
|
+
caption = child.text_content().strip()
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
rows = _table_rows(table)
|
|
206
|
+
grid: Dict[Tuple[int, int], str] = {}
|
|
207
|
+
max_col = 0
|
|
208
|
+
for r, row in enumerate(rows):
|
|
209
|
+
c = 0
|
|
210
|
+
for cell in _row_cells(row):
|
|
211
|
+
while (r, c) in grid:
|
|
212
|
+
c += 1
|
|
213
|
+
colspan = _int_attr(cell, 'colspan')
|
|
214
|
+
rowspan = _int_attr(cell, 'rowspan')
|
|
215
|
+
text = cell.text_content().strip()
|
|
216
|
+
for dr in range(rowspan):
|
|
217
|
+
for dc in range(colspan):
|
|
218
|
+
grid[(r + dr, c + dc)] = text
|
|
219
|
+
c += colspan
|
|
220
|
+
if c > max_col:
|
|
221
|
+
max_col = c
|
|
222
|
+
|
|
223
|
+
width = max_col
|
|
224
|
+
height = (max(r for r, _ in grid) + 1) if grid else 0
|
|
225
|
+
matrix = [[grid.get((r, c), '') for c in range(width)] for r in range(height)]
|
|
226
|
+
|
|
227
|
+
header_indexes = [i for i, row in enumerate(rows) if _in_thead(row)]
|
|
228
|
+
if not header_indexes and rows and _row_cells(rows[0]):
|
|
229
|
+
if all(cell.tag == 'th' for cell in _row_cells(rows[0])):
|
|
230
|
+
header_indexes = [0]
|
|
231
|
+
|
|
232
|
+
headers = [matrix[i] for i in header_indexes if i < height]
|
|
233
|
+
header_set = set(header_indexes)
|
|
234
|
+
data = [matrix[i] for i in range(height) if i not in header_set]
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
'caption': caption,
|
|
238
|
+
'headers': headers,
|
|
239
|
+
'rows': data,
|
|
240
|
+
'row_count': len(data),
|
|
241
|
+
'col_count': width,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def extract_tables(doc: ParsedDocument) -> List[Dict[str, Any]]:
|
|
246
|
+
"""Extract tables into header/data matrices with colspan/rowspan expansion."""
|
|
247
|
+
tables: List[Dict[str, Any]] = []
|
|
248
|
+
for element in doc.root.iter_elements():
|
|
249
|
+
if element.tag == 'table':
|
|
250
|
+
tables.append(_parse_table(element))
|
|
251
|
+
return tables
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def extract_images(doc: ParsedDocument) -> List[Dict[str, Any]]:
|
|
255
|
+
"""Extract img elements with src, alt, dimensions and srcset candidates."""
|
|
256
|
+
images: List[Dict[str, Any]] = []
|
|
257
|
+
for element in doc.root.iter_elements():
|
|
258
|
+
if element.tag != 'img':
|
|
259
|
+
continue
|
|
260
|
+
srcset = element.get_attr('srcset')
|
|
261
|
+
images.append({
|
|
262
|
+
'src': element.get_attr('src') or '',
|
|
263
|
+
'alt': element.get_attr('alt') or '',
|
|
264
|
+
'width': element.get_attr('width') or '',
|
|
265
|
+
'height': element.get_attr('height') or '',
|
|
266
|
+
'loading': element.get_attr('loading') or '',
|
|
267
|
+
'srcset': _parse_srcset(srcset) if srcset else [],
|
|
268
|
+
})
|
|
269
|
+
return images
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _input_field(node: Node) -> Dict[str, Any]:
|
|
273
|
+
return {
|
|
274
|
+
'tag': 'input',
|
|
275
|
+
'type': (node.get_attr('type') or 'text').lower(),
|
|
276
|
+
'name': node.get_attr('name') or '',
|
|
277
|
+
'value': node.get_attr('value') or '',
|
|
278
|
+
'placeholder': node.get_attr('placeholder') or '',
|
|
279
|
+
'checked': node.get_attr('checked') is not None,
|
|
280
|
+
'required': node.get_attr('required') is not None,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _select_field(node: Node) -> Dict[str, Any]:
|
|
285
|
+
options: List[Dict[str, Any]] = []
|
|
286
|
+
for child in node.iter_elements():
|
|
287
|
+
if child.tag != 'option':
|
|
288
|
+
continue
|
|
289
|
+
value = child.get_attr('value')
|
|
290
|
+
options.append({
|
|
291
|
+
'value': value if value is not None else child.text_content().strip(),
|
|
292
|
+
'text': child.text_content().strip(),
|
|
293
|
+
'selected': child.get_attr('selected') is not None,
|
|
294
|
+
})
|
|
295
|
+
return {
|
|
296
|
+
'tag': 'select',
|
|
297
|
+
'name': node.get_attr('name') or '',
|
|
298
|
+
'multiple': node.get_attr('multiple') is not None,
|
|
299
|
+
'required': node.get_attr('required') is not None,
|
|
300
|
+
'options': options,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _textarea_field(node: Node) -> Dict[str, Any]:
|
|
305
|
+
return {
|
|
306
|
+
'tag': 'textarea',
|
|
307
|
+
'name': node.get_attr('name') or '',
|
|
308
|
+
'value': node.text_content().strip(),
|
|
309
|
+
'placeholder': node.get_attr('placeholder') or '',
|
|
310
|
+
'required': node.get_attr('required') is not None,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _button_field(node: Node) -> Dict[str, Any]:
|
|
315
|
+
return {
|
|
316
|
+
'tag': 'button',
|
|
317
|
+
'type': (node.get_attr('type') or 'submit').lower(),
|
|
318
|
+
'name': node.get_attr('name') or '',
|
|
319
|
+
'value': node.get_attr('value') or '',
|
|
320
|
+
'text': node.text_content().strip(),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
_FIELD_HANDLERS = {
|
|
325
|
+
'input': _input_field,
|
|
326
|
+
'select': _select_field,
|
|
327
|
+
'textarea': _textarea_field,
|
|
328
|
+
'button': _button_field,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def extract_forms(doc: ParsedDocument) -> List[Dict[str, Any]]:
|
|
333
|
+
"""Extract forms with their input fields and default values."""
|
|
334
|
+
forms: List[Dict[str, Any]] = []
|
|
335
|
+
for element in doc.root.iter_elements():
|
|
336
|
+
if element.tag != 'form':
|
|
337
|
+
continue
|
|
338
|
+
form: Dict[str, Any] = {
|
|
339
|
+
'action': element.get_attr('action') or '',
|
|
340
|
+
'method': (element.get_attr('method') or 'get').lower(),
|
|
341
|
+
'enctype': element.get_attr('enctype')
|
|
342
|
+
or 'application/x-www-form-urlencoded',
|
|
343
|
+
'fields': [],
|
|
344
|
+
}
|
|
345
|
+
for field in element.iter_elements():
|
|
346
|
+
handler = _FIELD_HANDLERS.get(field.tag)
|
|
347
|
+
if handler:
|
|
348
|
+
form['fields'].append(handler(field))
|
|
349
|
+
forms.append(form)
|
|
350
|
+
return forms
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def extract_headings(doc: ParsedDocument) -> Dict[str, Any]:
|
|
354
|
+
"""Extract the h1-h6 outline plus word count and reading time."""
|
|
355
|
+
headings: List[Dict[str, Any]] = []
|
|
356
|
+
for element in doc.root.iter_elements():
|
|
357
|
+
if element.tag in _HEADING_TAGS:
|
|
358
|
+
headings.append({
|
|
359
|
+
'level': int(element.tag[1]),
|
|
360
|
+
'text': element.text_content().strip(),
|
|
361
|
+
})
|
|
362
|
+
words = len(extract_text(doc).split())
|
|
363
|
+
return {
|
|
364
|
+
'headings': headings,
|
|
365
|
+
'word_count': words,
|
|
366
|
+
'reading_time_minutes': round(words / _WORDS_PER_MINUTE, 1),
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def extract_scripts(doc: ParsedDocument) -> List[Dict[str, Any]]:
|
|
371
|
+
"""Inventory script elements, both inline and external."""
|
|
372
|
+
scripts: List[Dict[str, Any]] = []
|
|
373
|
+
for element in doc.root.iter_elements():
|
|
374
|
+
if element.tag != 'script':
|
|
375
|
+
continue
|
|
376
|
+
src = element.get_attr('src')
|
|
377
|
+
entry: Dict[str, Any] = {
|
|
378
|
+
'src': src or '',
|
|
379
|
+
'type': (element.get_attr('type') or '').lower(),
|
|
380
|
+
'async': element.get_attr('async') is not None,
|
|
381
|
+
'defer': element.get_attr('defer') is not None,
|
|
382
|
+
'inline': src is None,
|
|
383
|
+
}
|
|
384
|
+
if src is None:
|
|
385
|
+
entry['content'] = element.text_content().strip()
|
|
386
|
+
scripts.append(entry)
|
|
387
|
+
return scripts
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def extract_stylesheets(doc: ParsedDocument) -> List[Dict[str, Any]]:
|
|
391
|
+
"""Inventory stylesheets: link[rel~=stylesheet] and inline style elements."""
|
|
392
|
+
sheets: List[Dict[str, Any]] = []
|
|
393
|
+
for element in doc.root.iter_elements():
|
|
394
|
+
if element.tag == 'link':
|
|
395
|
+
rel = (element.get_attr('rel') or '').lower().split()
|
|
396
|
+
if 'stylesheet' in rel:
|
|
397
|
+
sheets.append({
|
|
398
|
+
'href': element.get_attr('href') or '',
|
|
399
|
+
'media': element.get_attr('media') or '',
|
|
400
|
+
'inline': False,
|
|
401
|
+
})
|
|
402
|
+
elif element.tag == 'style':
|
|
403
|
+
sheets.append({
|
|
404
|
+
'media': element.get_attr('media') or '',
|
|
405
|
+
'inline': True,
|
|
406
|
+
'content': element.text_content().strip(),
|
|
407
|
+
})
|
|
408
|
+
return sheets
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _is_boilerplate(node: Node) -> bool:
|
|
412
|
+
if node.tag in _BOILERPLATE_TAGS:
|
|
413
|
+
return True
|
|
414
|
+
identity = f"{node.get_attr('class') or ''} {node.get_attr('id') or ''}"
|
|
415
|
+
return bool(_BOILERPLATE_NAME_RE.search(identity))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def extract_text(doc: ParsedDocument) -> str:
|
|
419
|
+
"""Extract readable text, dropping scripts/styles and common boilerplate
|
|
420
|
+
elements while preserving block-level line structure."""
|
|
421
|
+
parts: List[str] = []
|
|
422
|
+
|
|
423
|
+
def walk(node: Node) -> None:
|
|
424
|
+
for child in node.children:
|
|
425
|
+
if isinstance(child, str):
|
|
426
|
+
parts.append(child)
|
|
427
|
+
continue
|
|
428
|
+
if child.is_comment:
|
|
429
|
+
continue
|
|
430
|
+
if child.tag in _SKIP_TEXT_TAGS or _is_boilerplate(child):
|
|
431
|
+
continue
|
|
432
|
+
if child.tag == 'br':
|
|
433
|
+
parts.append('\n')
|
|
434
|
+
continue
|
|
435
|
+
block = child.tag in BLOCK_TAGS
|
|
436
|
+
if block:
|
|
437
|
+
parts.append('\n')
|
|
438
|
+
walk(child)
|
|
439
|
+
if block:
|
|
440
|
+
parts.append('\n')
|
|
441
|
+
|
|
442
|
+
walk(doc.root)
|
|
443
|
+
text = ''.join(parts)
|
|
444
|
+
text = re.sub(r'[ \t]+', ' ', text)
|
|
445
|
+
text = re.sub(r' ?\n ?', '\n', text)
|
|
446
|
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
447
|
+
return text.strip()
|
webreader/formatter.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""HTML pretty-printer producing indented, readable markup."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from html import escape as _escape
|
|
5
|
+
from typing import Any, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .core import (
|
|
8
|
+
BLOCK_TAGS,
|
|
9
|
+
VOID_ELEMENTS,
|
|
10
|
+
ParsedDocument,
|
|
11
|
+
Node,
|
|
12
|
+
parse_raw,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_BLOCKISH = BLOCK_TAGS | {'html', 'head', 'body'}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pretty_html(html: Union[str, bytes, ParsedDocument], indent: str = ' ',
|
|
19
|
+
config: Optional[Any] = None) -> str:
|
|
20
|
+
"""Reformat HTML with one block element per line and indented nesting."""
|
|
21
|
+
doc = html if isinstance(html, ParsedDocument) else parse_raw(html, config)
|
|
22
|
+
lines: List[str] = []
|
|
23
|
+
if doc.doctype:
|
|
24
|
+
lines.append(doc.doctype)
|
|
25
|
+
for child in doc.root.children:
|
|
26
|
+
if isinstance(child, str):
|
|
27
|
+
text = _collapse(child).strip()
|
|
28
|
+
if text:
|
|
29
|
+
lines.append(_escape(text, quote=False))
|
|
30
|
+
elif child.is_comment:
|
|
31
|
+
lines.append(f'<!--{child.text_content()}-->')
|
|
32
|
+
else:
|
|
33
|
+
lines.extend(_render(child, 0, indent))
|
|
34
|
+
return '\n'.join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _render(node: Node, level: int, indent: str) -> List[str]:
|
|
38
|
+
pad = indent * level
|
|
39
|
+
inner = indent * (level + 1)
|
|
40
|
+
opening = _opening_tag(node)
|
|
41
|
+
if node.tag in VOID_ELEMENTS:
|
|
42
|
+
return [pad + opening]
|
|
43
|
+
if node.tag in ('pre', 'textarea'):
|
|
44
|
+
return [pad + node.to_html()]
|
|
45
|
+
|
|
46
|
+
parts: List[Any] = []
|
|
47
|
+
for child in node.children:
|
|
48
|
+
if isinstance(child, str):
|
|
49
|
+
parts.append(('inline', _escape(_collapse(child), quote=False)))
|
|
50
|
+
elif child.is_comment:
|
|
51
|
+
parts.append(('inline', f'<!--{child.text_content()}-->'))
|
|
52
|
+
elif child.tag in _BLOCKISH:
|
|
53
|
+
parts.append(('block', child))
|
|
54
|
+
else:
|
|
55
|
+
parts.append(('inline', child.to_html()))
|
|
56
|
+
|
|
57
|
+
has_blocks = any(kind == 'block' for kind, _ in parts)
|
|
58
|
+
if not has_blocks:
|
|
59
|
+
text = ''.join(text for kind, text in parts).strip()
|
|
60
|
+
return [pad + opening + text + f'</{node.tag}>']
|
|
61
|
+
|
|
62
|
+
lines = [pad + opening]
|
|
63
|
+
buffer: List[Any] = []
|
|
64
|
+
for kind, value in parts:
|
|
65
|
+
if kind == 'inline':
|
|
66
|
+
buffer.append(value)
|
|
67
|
+
continue
|
|
68
|
+
text = ''.join(buffer).strip()
|
|
69
|
+
buffer = []
|
|
70
|
+
if text:
|
|
71
|
+
lines.append(inner + text)
|
|
72
|
+
lines.extend(_render(value, level + 1, indent))
|
|
73
|
+
text = ''.join(buffer).strip()
|
|
74
|
+
if text:
|
|
75
|
+
lines.append(inner + text)
|
|
76
|
+
lines.append(pad + f'</{node.tag}>')
|
|
77
|
+
return lines
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _opening_tag(node: Node) -> str:
|
|
81
|
+
attrs = ''.join(
|
|
82
|
+
f' {name}' if value == '' else f' {name}="{_escape(value, quote=True)}"'
|
|
83
|
+
for name, value in node.attrs.items()
|
|
84
|
+
)
|
|
85
|
+
return f'<{node.tag}{attrs}>'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collapse(text: str) -> str:
|
|
89
|
+
return re.sub(r'\s+', ' ', text)
|