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/markdown.py ADDED
@@ -0,0 +1,237 @@
1
+ """Convert parsed HTML documents to Markdown."""
2
+
3
+ import re
4
+ from typing import Any, List, Optional, Union
5
+
6
+ from .core import BLOCK_TAGS, Node, ParsedDocument, parse_raw
7
+
8
+ _SKIP_TAGS = {
9
+ 'script', 'style', 'noscript', 'template',
10
+ 'head', 'meta', 'link', 'title',
11
+ }
12
+ _HEADING_TAGS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
13
+ _CONTAINER_TAGS = BLOCK_TAGS | {'html', 'body', 'figure', 'picture', 'details'}
14
+
15
+
16
+ def to_markdown(target: Union[str, bytes, ParsedDocument],
17
+ config: Optional[Any] = None) -> str:
18
+ """Convert HTML or an already parsed document to Markdown."""
19
+ doc = target if isinstance(target, ParsedDocument) else parse_raw(target, config)
20
+ return document_to_markdown(doc)
21
+
22
+
23
+ def document_to_markdown(doc: ParsedDocument) -> str:
24
+ """Render a parsed document as Markdown."""
25
+ blocks: List[str] = []
26
+ _render_children(doc.root, blocks)
27
+ return '\n\n'.join(block for block in blocks if block.strip())
28
+
29
+
30
+ def _render_children(node: Node, out: List[str]) -> None:
31
+ for child in node.children:
32
+ if isinstance(child, str):
33
+ text = _collapse(child)
34
+ if text.strip():
35
+ out.append(text.strip())
36
+ elif not child.is_comment and child.tag not in _SKIP_TAGS:
37
+ _render_block(child, out)
38
+
39
+
40
+ def _render_block(node: Node, out: List[str]) -> None:
41
+ tag = node.tag
42
+ if tag in _HEADING_TAGS:
43
+ text = _inline(node).strip()
44
+ if text:
45
+ out.append('#' * int(tag[1]) + ' ' + text)
46
+ elif tag == 'p':
47
+ text = _inline(node).strip()
48
+ if text:
49
+ out.append(text)
50
+ elif tag == 'hr':
51
+ out.append('---')
52
+ elif tag == 'pre':
53
+ out.append(_fenced_code(node))
54
+ elif tag == 'blockquote':
55
+ inner: List[str] = []
56
+ _render_children(node, inner)
57
+ joined = '\n\n'.join(inner)
58
+ out.append('\n'.join('> ' + line for line in joined.split('\n')))
59
+ elif tag in ('ul', 'ol'):
60
+ out.append(_list(node, 0))
61
+ elif tag == 'table':
62
+ text = _markdown_table(node)
63
+ if text:
64
+ out.append(text)
65
+ elif tag in _CONTAINER_TAGS:
66
+ _render_children(node, out)
67
+ else:
68
+ parts: List[str] = []
69
+ _inline_node(node, parts)
70
+ text = ''.join(parts).strip()
71
+ if text:
72
+ out.append(text)
73
+
74
+
75
+ def _inline(node: Node) -> str:
76
+ parts: List[str] = []
77
+ _inline_node(node, parts)
78
+ return ''.join(parts)
79
+
80
+
81
+ def _inline_node(node: Node, parts: List[str]) -> None:
82
+ tag = node.tag
83
+ if tag == 'br':
84
+ parts.append('\n')
85
+ return
86
+ if tag == 'img':
87
+ alt = node.get_attr('alt') or ''
88
+ src = node.get_attr('src') or ''
89
+ title = node.get_attr('title')
90
+ suffix = f' "{title}"' if title else ''
91
+ parts.append(f'![{alt}]({src}{suffix})')
92
+ return
93
+ if tag == 'a':
94
+ href = node.get_attr('href') or ''
95
+ title = node.get_attr('title')
96
+ label = _flat(node).strip()
97
+ if href:
98
+ suffix = f' "{title}"' if title else ''
99
+ parts.append(f'[{label}]({href}{suffix})')
100
+ else:
101
+ parts.append(label)
102
+ return
103
+ if tag in ('strong', 'b'):
104
+ inner = _flat(node).strip()
105
+ if inner:
106
+ parts.append(f'**{inner}**')
107
+ return
108
+ if tag in ('em', 'i'):
109
+ inner = _flat(node).strip()
110
+ if inner:
111
+ parts.append(f'*{inner}*')
112
+ return
113
+ if tag in ('del', 's'):
114
+ inner = _flat(node).strip()
115
+ if inner:
116
+ parts.append(f'~~{inner}~~')
117
+ return
118
+ if tag == 'code':
119
+ parts.append(_code_span(node.text_content()))
120
+ return
121
+ if tag in ('ul', 'ol'):
122
+ return
123
+ for child in node.children:
124
+ if isinstance(child, str):
125
+ parts.append(_collapse(child))
126
+ elif not child.is_comment and child.tag not in _SKIP_TAGS:
127
+ _inline_node(child, parts)
128
+
129
+
130
+ def _flat(node: Node) -> str:
131
+ parts: List[str] = []
132
+
133
+ def walk(current: Node) -> None:
134
+ for child in current.children:
135
+ if isinstance(child, str):
136
+ parts.append(_collapse(child))
137
+ elif child.tag == 'br':
138
+ parts.append(' ')
139
+ elif not child.is_comment and child.tag not in _SKIP_TAGS:
140
+ walk(child)
141
+
142
+ walk(node)
143
+ return ''.join(parts)
144
+
145
+
146
+ def _code_span(text: str) -> str:
147
+ text = text.strip()
148
+ if '`' not in text:
149
+ return f'`{text}`'
150
+ return f'`` {text} ``'
151
+
152
+
153
+ def _fenced_code(node: Node) -> str:
154
+ language = ''
155
+ for child in node.child_elements():
156
+ if child.tag == 'code':
157
+ match = re.search(
158
+ r'(?:^|\s)(?:language|lang)-([\w#+.-]+)',
159
+ child.get_attr('class') or '',
160
+ )
161
+ if match:
162
+ language = match.group(1)
163
+ content = node.text_content().strip('\n')
164
+ return f'```{language}\n{content}\n```'
165
+
166
+
167
+ def _list(node: Node, depth: int) -> str:
168
+ ordered = node.tag == 'ol'
169
+ index = 1
170
+ if ordered:
171
+ start = node.get_attr('start')
172
+ if start and start.lstrip('-').isdigit():
173
+ index = int(start)
174
+ lines: List[str] = []
175
+ for child in node.child_elements():
176
+ if child.tag != 'li':
177
+ continue
178
+ marker = f'{index}. ' if ordered else '- '
179
+ prefix = ' ' * depth + marker
180
+ lines.append(prefix + _li_content(child, depth))
181
+ if ordered:
182
+ index += 1
183
+ return '\n'.join(lines)
184
+
185
+
186
+ def _li_content(li: Node, depth: int) -> str:
187
+ parts: List[str] = []
188
+ tail: List[str] = []
189
+ for child in li.children:
190
+ if isinstance(child, str):
191
+ parts.append(_collapse(child))
192
+ elif child.is_comment or child.tag in _SKIP_TAGS:
193
+ continue
194
+ elif child.tag in ('ul', 'ol'):
195
+ tail.append(_list(child, depth + 1))
196
+ elif child.tag == 'p':
197
+ text = _inline(child).strip()
198
+ if text:
199
+ parts.append(text)
200
+ else:
201
+ parts.append(_inline(child))
202
+ head = re.sub(r'[ \t]+', ' ', ''.join(parts)).strip()
203
+ if tail:
204
+ return head + '\n' + '\n'.join(tail)
205
+ return head
206
+
207
+
208
+ def _markdown_table(node: Node) -> str:
209
+ from .extractors import _parse_table
210
+ data = _parse_table(node)
211
+ header_rows = data['headers']
212
+ body_rows = list(data['rows'])
213
+ if header_rows:
214
+ header = header_rows[0]
215
+ body_rows = header_rows[1:] + body_rows
216
+ elif body_rows:
217
+ header = body_rows.pop(0)
218
+ else:
219
+ return ''
220
+ all_rows = [header] + body_rows
221
+ width = max(len(row) for row in all_rows)
222
+ if width == 0:
223
+ return ''
224
+
225
+ def pad(row: List[str]) -> List[str]:
226
+ return [str(cell).replace('|', '\\|') for cell in row] + [''] * (width - len(row))
227
+
228
+ lines = [
229
+ '| ' + ' | '.join(pad(header)) + ' |',
230
+ '|' + '---|' * width,
231
+ ]
232
+ lines.extend('| ' + ' | '.join(pad(row)) + ' |' for row in body_rows)
233
+ return '\n'.join(lines)
234
+
235
+
236
+ def _collapse(text: str) -> str:
237
+ return re.sub(r'\s+', ' ', text)
webreader/sanitizer.py ADDED
@@ -0,0 +1,117 @@
1
+ """Tree-based HTML sanitization driven by parser configuration."""
2
+
3
+ import re
4
+ from html import escape as _escape
5
+ from typing import Any, Dict, List, Optional, Set, Tuple
6
+
7
+ from .core import Node, ParsedDocument
8
+
9
+ _DROP_CONTENT_TAGS = {'script', 'style', 'noscript', 'template'}
10
+
11
+ SAFE_ATTRS = frozenset({
12
+ 'href', 'src', 'alt', 'title', 'class', 'id', 'colspan', 'rowspan',
13
+ 'target', 'rel', 'width', 'height', 'name', 'lang', 'datetime',
14
+ 'cite', 'start', 'reversed', 'value', 'type',
15
+ })
16
+
17
+ _DANGEROUS_URL_RE = re.compile(r'^\s*(?:javascript|vbscript)\s*:', re.IGNORECASE)
18
+ _SAFE_DATA_URL_RE = re.compile(
19
+ r'^\s*data:image/(?:png|jpe?g|gif|webp|bmp)\s*;', re.IGNORECASE
20
+ )
21
+
22
+ _FALLBACK_ALLOWED_TAGS = {'p', 'br', 'b', 'i', 'a', 'ul', 'ol', 'li', 'div', 'span'}
23
+
24
+
25
+ def sanitize_document(doc: ParsedDocument,
26
+ allowed_tags: Optional[Set[str]] = None) -> Tuple[str, Dict[str, Any]]:
27
+ """Return (clean_html, removal_report) for a parsed document.
28
+
29
+ Elements outside the allowed set are unwrapped (their inner text is
30
+ kept); script/style-like elements are dropped entirely; attributes are
31
+ filtered through SAFE_ATTRS, event handlers are removed and dangerous
32
+ URL schemes (javascript:, vbscript:, unsafe data:) are blocked.
33
+ """
34
+ allowed = _resolve_allowed(doc, allowed_tags)
35
+ report: Dict[str, Any] = {
36
+ 'allowed_tags': sorted(allowed),
37
+ 'removed_tags': {},
38
+ 'removed_attributes': {},
39
+ 'blocked_urls': 0,
40
+ 'removed_comments': 0,
41
+ 'threats_detected': False,
42
+ }
43
+ cleaned = _clean_children(doc.root, allowed, report)
44
+ html = ''.join(
45
+ _escape(child, quote=False) if isinstance(child, str) else child.to_html()
46
+ for child in cleaned
47
+ )
48
+ return html, report
49
+
50
+
51
+ def _resolve_allowed(doc: ParsedDocument,
52
+ allowed_tags: Optional[Set[str]]) -> Set[str]:
53
+ if allowed_tags is not None:
54
+ return {tag.lower() for tag in allowed_tags}
55
+ from_config = doc.config.get('allowed_tags')
56
+ if from_config:
57
+ return {tag.lower() for tag in from_config}
58
+ return set(_FALLBACK_ALLOWED_TAGS)
59
+
60
+
61
+ _URL_CONTROL_CHARS_RE = re.compile(r'[\t\n\r\f\v\0]')
62
+
63
+
64
+ def _is_dangerous_url(url: str) -> bool:
65
+ url = _URL_CONTROL_CHARS_RE.sub('', url).strip()
66
+ if _DANGEROUS_URL_RE.match(url):
67
+ return True
68
+ if url.lower().startswith('data:'):
69
+ return not _SAFE_DATA_URL_RE.match(url)
70
+ return False
71
+
72
+
73
+ def _clean_attrs(node: Node, report: Dict[str, Any]) -> Dict[str, str]:
74
+ attrs: Dict[str, str] = {}
75
+ for name, value in node.attrs.items():
76
+ lowered = name.lower()
77
+ if lowered.startswith('on'):
78
+ _count(report['removed_attributes'], name)
79
+ report['threats_detected'] = True
80
+ continue
81
+ if lowered not in SAFE_ATTRS:
82
+ _count(report['removed_attributes'], name)
83
+ continue
84
+ if lowered in ('href', 'src') and _is_dangerous_url(value or ''):
85
+ report['blocked_urls'] += 1
86
+ report['threats_detected'] = True
87
+ continue
88
+ attrs[name] = value
89
+ return attrs
90
+
91
+
92
+ def _clean_children(node: Node, allowed: Set[str],
93
+ report: Dict[str, Any]) -> List[Any]:
94
+ out: List[Any] = []
95
+ for child in node.children:
96
+ if isinstance(child, str):
97
+ out.append(child)
98
+ continue
99
+ if child.is_comment:
100
+ report['removed_comments'] += 1
101
+ continue
102
+ if child.tag in _DROP_CONTENT_TAGS:
103
+ _count(report['removed_tags'], child.tag)
104
+ report['threats_detected'] = True
105
+ continue
106
+ if child.tag not in allowed:
107
+ _count(report['removed_tags'], child.tag)
108
+ out.extend(_clean_children(child, allowed, report))
109
+ continue
110
+ cleaned = Node(child.tag, _clean_attrs(child, report))
111
+ cleaned.children = _clean_children(child, allowed, report)
112
+ out.append(cleaned)
113
+ return out
114
+
115
+
116
+ def _count(counter: Dict[str, int], name: str) -> None:
117
+ counter[name] = counter.get(name, 0) + 1