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/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'')
|
|
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
|