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.
@@ -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)