webless 0.1.0__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.
- webless/__init__.py +85 -0
- webless/_engines.py +604 -0
- webless/_fetch.py +145 -0
- webless/_html.py +489 -0
- webless/_http.py +281 -0
- webless/_search.py +90 -0
- webless/_sync.py +45 -0
- webless/cli.py +121 -0
- webless/py.typed +0 -0
- webless-0.1.0.dist-info/METADATA +241 -0
- webless-0.1.0.dist-info/RECORD +14 -0
- webless-0.1.0.dist-info/WHEEL +4 -0
- webless-0.1.0.dist-info/entry_points.txt +3 -0
- webless-0.1.0.dist-info/licenses/LICENSE +21 -0
webless/_fetch.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The public fetch API.
|
|
2
|
+
|
|
3
|
+
One call, `fetch`, retrieves a URL and returns its readable content rather
|
|
4
|
+
than its markup. A page's HTML is mostly navigation, scripts, and layout, so
|
|
5
|
+
the default trims to the article body and renders headings, lists, code
|
|
6
|
+
blocks, and links as Markdown -- small enough to read or feed to a model, and
|
|
7
|
+
structured enough to still quote from and follow links out of.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from ._html import (
|
|
16
|
+
html_to_markdown,
|
|
17
|
+
main_content,
|
|
18
|
+
page_description,
|
|
19
|
+
page_title,
|
|
20
|
+
parse_html,
|
|
21
|
+
)
|
|
22
|
+
from ._http import WebError, fetch_url, host_of, normalize_url
|
|
23
|
+
|
|
24
|
+
FORMATS = ("markdown", "text", "raw")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class Page:
|
|
29
|
+
"""A fetched page, rendered into the requested shape."""
|
|
30
|
+
|
|
31
|
+
url: str
|
|
32
|
+
final_url: str
|
|
33
|
+
status: int
|
|
34
|
+
content_type: str
|
|
35
|
+
title: str
|
|
36
|
+
description: str
|
|
37
|
+
content: str
|
|
38
|
+
truncated: bool = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def host(self) -> str:
|
|
42
|
+
"""The hostname the page was finally served from."""
|
|
43
|
+
return host_of(self.final_url)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def redirected(self) -> bool:
|
|
47
|
+
"""Whether the final URL differs from the one that was requested."""
|
|
48
|
+
return self.final_url != self.url
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return self.content
|
|
52
|
+
|
|
53
|
+
def __len__(self) -> int:
|
|
54
|
+
return len(self.content)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def fetch(
|
|
58
|
+
url: str,
|
|
59
|
+
*,
|
|
60
|
+
format: str = "markdown",
|
|
61
|
+
full_page: bool = False,
|
|
62
|
+
max_chars: int | None = None,
|
|
63
|
+
offset: int = 0,
|
|
64
|
+
timeout: float | None = None,
|
|
65
|
+
) -> Page:
|
|
66
|
+
"""Fetch a URL and return its content.
|
|
67
|
+
|
|
68
|
+
`format` is ``markdown`` for the main content as Markdown, ``text`` for
|
|
69
|
+
plain text with no markup, or ``raw`` for the untouched response body.
|
|
70
|
+
JSON responses are returned pretty-printed whatever the format, except
|
|
71
|
+
``raw``.
|
|
72
|
+
|
|
73
|
+
`full_page` keeps navigation, sidebars, and footers instead of trimming to
|
|
74
|
+
the main content -- use it when the part you want is the chrome, such as a
|
|
75
|
+
documentation index or a link list.
|
|
76
|
+
|
|
77
|
+
`max_chars` and `offset` read a long page in sections; `Page.truncated`
|
|
78
|
+
says whether anything was left behind. Raises `WebError` on a transport
|
|
79
|
+
failure, a non-2xx status, or a URL that resolves to a private address.
|
|
80
|
+
"""
|
|
81
|
+
if format not in FORMATS:
|
|
82
|
+
raise ValueError(f"format must be one of {', '.join(FORMATS)}; got {format!r}")
|
|
83
|
+
if offset < 0:
|
|
84
|
+
raise ValueError("offset cannot be negative")
|
|
85
|
+
host = host_of(url)
|
|
86
|
+
if not host or any(ch.isspace() for ch in host):
|
|
87
|
+
raise ValueError(f"{url!r} is not a usable URL")
|
|
88
|
+
|
|
89
|
+
target = normalize_url(url)
|
|
90
|
+
kwargs = {} if timeout is None else {"timeout": timeout}
|
|
91
|
+
response = await fetch_url(target, **kwargs)
|
|
92
|
+
|
|
93
|
+
body, title, description = render(response, format=format, full_page=full_page)
|
|
94
|
+
|
|
95
|
+
section = body[offset:] if max_chars is None else body[offset : offset + max_chars]
|
|
96
|
+
truncated = offset + len(section) < len(body)
|
|
97
|
+
|
|
98
|
+
return Page(
|
|
99
|
+
url=target,
|
|
100
|
+
final_url=response.final_url,
|
|
101
|
+
status=response.status,
|
|
102
|
+
content_type=response.content_type,
|
|
103
|
+
title=title,
|
|
104
|
+
description=description,
|
|
105
|
+
content=section,
|
|
106
|
+
truncated=truncated,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render(response, *, format: str = "markdown", full_page: bool = False) -> tuple[str, str, str]:
|
|
111
|
+
"""Turn a response body into content, title, and description.
|
|
112
|
+
|
|
113
|
+
Split out from `fetch` so a caller holding a response from somewhere else
|
|
114
|
+
-- a cache, a test fixture, its own client -- can reuse the same
|
|
115
|
+
conversion without a network round trip.
|
|
116
|
+
"""
|
|
117
|
+
if format == "raw":
|
|
118
|
+
return response.text, "", ""
|
|
119
|
+
|
|
120
|
+
if response.is_json:
|
|
121
|
+
try:
|
|
122
|
+
return json.dumps(json.loads(response.text), indent=2), "", ""
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
return response.text, "", ""
|
|
125
|
+
|
|
126
|
+
if not response.is_html:
|
|
127
|
+
return response.text, "", ""
|
|
128
|
+
|
|
129
|
+
root = parse_html(response.text)
|
|
130
|
+
title = page_title(root)
|
|
131
|
+
description = page_description(root)
|
|
132
|
+
|
|
133
|
+
if format == "text":
|
|
134
|
+
node = root if full_page else main_content(root)
|
|
135
|
+
return node.text(), title, description
|
|
136
|
+
|
|
137
|
+
markdown = html_to_markdown(
|
|
138
|
+
response.text, base_url=response.final_url, article=not full_page
|
|
139
|
+
)
|
|
140
|
+
if description and description not in markdown[:1000]:
|
|
141
|
+
markdown = f"> {description}\n\n{markdown}"
|
|
142
|
+
return markdown, title, description
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ["fetch", "render", "Page", "WebError"]
|
webless/_html.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""A dependency-free HTML layer for the web tools.
|
|
2
|
+
|
|
3
|
+
Two jobs live here. The first is a tolerant parser that turns a page into a
|
|
4
|
+
small tree with just enough selector support for the search engines to pick
|
|
5
|
+
their result rows out of a results page. The second is a renderer that turns
|
|
6
|
+
that tree into Markdown, which is the shape `fetch` returns.
|
|
7
|
+
|
|
8
|
+
Both are written against the standard library on purpose. The web tools are
|
|
9
|
+
part of the built-in suite, so anything they need is a dependency of every
|
|
10
|
+
install; a parser that costs nothing is worth more here than one that handles
|
|
11
|
+
every malformed page perfectly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from html import unescape
|
|
19
|
+
from html.parser import HTMLParser
|
|
20
|
+
|
|
21
|
+
VOID_TAGS = {
|
|
22
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
23
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
SKIPPED_TAGS = {"script", "style", "noscript", "template", "svg", "canvas"}
|
|
27
|
+
|
|
28
|
+
BOILERPLATE_TAGS = {"nav", "footer", "aside", "form"}
|
|
29
|
+
|
|
30
|
+
CONTAINER_HINTS = ("content", "article", "post", "main", "entry", "markdown", "prose")
|
|
31
|
+
|
|
32
|
+
BLOCK_TAGS = {
|
|
33
|
+
"address", "article", "aside", "blockquote", "div", "dl", "dt", "dd",
|
|
34
|
+
"fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3",
|
|
35
|
+
"h4", "h5", "h6", "header", "hr", "li", "main", "nav", "ol", "p", "pre",
|
|
36
|
+
"section", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
IMPLICIT_CLOSE = {
|
|
40
|
+
"li": {"li"},
|
|
41
|
+
"p": {"p"},
|
|
42
|
+
"dt": {"dt", "dd"},
|
|
43
|
+
"dd": {"dt", "dd"},
|
|
44
|
+
"tr": {"tr"},
|
|
45
|
+
"td": {"td", "th"},
|
|
46
|
+
"th": {"td", "th"},
|
|
47
|
+
"option": {"option"},
|
|
48
|
+
"thead": {"thead", "tbody"},
|
|
49
|
+
"tbody": {"tbody", "thead"},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
TEXT = "#text"
|
|
53
|
+
|
|
54
|
+
_WS_RE = re.compile(r"[ \t\r\f\v]+")
|
|
55
|
+
_BLANK_RE = re.compile(r"\n{3,}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Node:
|
|
60
|
+
"""One element or text run in a parsed document."""
|
|
61
|
+
|
|
62
|
+
tag: str
|
|
63
|
+
attrs: dict[str, str] = field(default_factory=dict)
|
|
64
|
+
children: list[Node] = field(default_factory=list)
|
|
65
|
+
parent: Node | None = None
|
|
66
|
+
data: str = ""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def classes(self) -> set[str]:
|
|
70
|
+
"""The element's class names, as a set."""
|
|
71
|
+
return set(self.attrs.get("class", "").split())
|
|
72
|
+
|
|
73
|
+
def get(self, name: str, default: str = "") -> str:
|
|
74
|
+
"""Return an attribute value, or `default` when it is absent."""
|
|
75
|
+
return self.attrs.get(name, default)
|
|
76
|
+
|
|
77
|
+
def descendants(self) -> list[Node]:
|
|
78
|
+
"""Every node below this one, in document order."""
|
|
79
|
+
out: list[Node] = []
|
|
80
|
+
stack = list(reversed(self.children))
|
|
81
|
+
while stack:
|
|
82
|
+
node = stack.pop()
|
|
83
|
+
out.append(node)
|
|
84
|
+
stack.extend(reversed(node.children))
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
def raw_text(self) -> str:
|
|
88
|
+
"""The subtree's text with whitespace intact.
|
|
89
|
+
|
|
90
|
+
`text` collapses runs of whitespace, which is right for prose and
|
|
91
|
+
wrong for a code block, where the indentation is the meaning.
|
|
92
|
+
"""
|
|
93
|
+
if self.tag in SKIPPED_TAGS:
|
|
94
|
+
return ""
|
|
95
|
+
if self.tag == TEXT:
|
|
96
|
+
return self.data
|
|
97
|
+
return "".join(child.raw_text() for child in self.children)
|
|
98
|
+
|
|
99
|
+
def text(self, *, strip: bool = True) -> str:
|
|
100
|
+
"""The concatenated text of this subtree, with scripts left out."""
|
|
101
|
+
parts: list[str] = []
|
|
102
|
+
self._collect_text(parts)
|
|
103
|
+
joined = "".join(parts)
|
|
104
|
+
joined = _WS_RE.sub(" ", joined)
|
|
105
|
+
return joined.strip() if strip else joined
|
|
106
|
+
|
|
107
|
+
def _collect_text(self, parts: list[str]) -> None:
|
|
108
|
+
if self.tag in SKIPPED_TAGS:
|
|
109
|
+
return
|
|
110
|
+
if self.tag == TEXT:
|
|
111
|
+
parts.append(self.data)
|
|
112
|
+
return
|
|
113
|
+
if self.tag in BLOCK_TAGS or self.tag == "br":
|
|
114
|
+
parts.append(" ")
|
|
115
|
+
for child in self.children:
|
|
116
|
+
child._collect_text(parts)
|
|
117
|
+
if self.tag in BLOCK_TAGS:
|
|
118
|
+
parts.append(" ")
|
|
119
|
+
|
|
120
|
+
def select(self, selector: str) -> list[Node]:
|
|
121
|
+
"""Return every descendant matching `selector`, in document order.
|
|
122
|
+
|
|
123
|
+
The supported grammar is the part of CSS the engines actually use:
|
|
124
|
+
comma-separated alternatives, descendant combinators, and simple
|
|
125
|
+
selectors built from a tag name, `.class`, and `#id`.
|
|
126
|
+
"""
|
|
127
|
+
seen: list[Node] = []
|
|
128
|
+
for group in (g.strip() for g in selector.split(",")):
|
|
129
|
+
if not group:
|
|
130
|
+
continue
|
|
131
|
+
for node in self._select_group(group):
|
|
132
|
+
if not any(node is s for s in seen):
|
|
133
|
+
seen.append(node)
|
|
134
|
+
order = {id(n): i for i, n in enumerate(self.descendants())}
|
|
135
|
+
seen.sort(key=lambda n: order.get(id(n), 0))
|
|
136
|
+
return seen
|
|
137
|
+
|
|
138
|
+
def select_one(self, selector: str) -> Node | None:
|
|
139
|
+
"""Return the first descendant matching `selector`, or None."""
|
|
140
|
+
matches = self.select(selector)
|
|
141
|
+
return matches[0] if matches else None
|
|
142
|
+
|
|
143
|
+
def _select_group(self, group: str) -> list[Node]:
|
|
144
|
+
steps = group.split()
|
|
145
|
+
current = [self]
|
|
146
|
+
for step in steps:
|
|
147
|
+
nxt: list[Node] = []
|
|
148
|
+
for node in current:
|
|
149
|
+
nxt.extend(d for d in node.descendants() if _matches(d, step))
|
|
150
|
+
current = nxt
|
|
151
|
+
return current
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _matches(node: Node, simple: str) -> bool:
|
|
155
|
+
"""True when `node` satisfies one simple selector such as ``li.b_algo``."""
|
|
156
|
+
if node.tag == TEXT:
|
|
157
|
+
return False
|
|
158
|
+
match = re.match(r"^([a-zA-Z0-9_-]+)?((?:[.#][^.#]+)*)$", simple)
|
|
159
|
+
if match is None:
|
|
160
|
+
return False
|
|
161
|
+
tag, rest = match.group(1), match.group(2) or ""
|
|
162
|
+
if tag and tag != node.tag:
|
|
163
|
+
return False
|
|
164
|
+
for part in re.findall(r"[.#][^.#]+", rest):
|
|
165
|
+
value = part[1:]
|
|
166
|
+
if part[0] == "." and value not in node.classes:
|
|
167
|
+
return False
|
|
168
|
+
if part[0] == "#" and value != node.get("id"):
|
|
169
|
+
return False
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _TreeBuilder(HTMLParser):
|
|
174
|
+
"""Builds a `Node` tree, closing tags the page forgot to close.
|
|
175
|
+
|
|
176
|
+
`void_tags` is a parameter rather than a constant because the same builder
|
|
177
|
+
parses XML feeds, where a tag like `<link>` carries text and closes
|
|
178
|
+
normally instead of standing alone as it does in HTML.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, void_tags: set[str] = VOID_TAGS) -> None:
|
|
182
|
+
super().__init__(convert_charrefs=True)
|
|
183
|
+
self.void_tags = void_tags
|
|
184
|
+
self.root = Node(tag="#document")
|
|
185
|
+
self._stack = [self.root]
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def _current(self) -> Node:
|
|
189
|
+
return self._stack[-1]
|
|
190
|
+
|
|
191
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
192
|
+
for open_tag in IMPLICIT_CLOSE.get(tag, ()):
|
|
193
|
+
if any(n.tag == open_tag for n in self._stack[1:]):
|
|
194
|
+
self._close_to(open_tag)
|
|
195
|
+
break
|
|
196
|
+
node = Node(
|
|
197
|
+
tag=tag,
|
|
198
|
+
attrs={k.lower(): (v or "") for k, v in attrs},
|
|
199
|
+
parent=self._current,
|
|
200
|
+
)
|
|
201
|
+
self._current.children.append(node)
|
|
202
|
+
if tag not in self.void_tags:
|
|
203
|
+
self._stack.append(node)
|
|
204
|
+
|
|
205
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
206
|
+
node = Node(
|
|
207
|
+
tag=tag,
|
|
208
|
+
attrs={k.lower(): (v or "") for k, v in attrs},
|
|
209
|
+
parent=self._current,
|
|
210
|
+
)
|
|
211
|
+
self._current.children.append(node)
|
|
212
|
+
|
|
213
|
+
def handle_endtag(self, tag: str) -> None:
|
|
214
|
+
if tag in self.void_tags:
|
|
215
|
+
return
|
|
216
|
+
self._close_to(tag)
|
|
217
|
+
|
|
218
|
+
def _close_to(self, tag: str) -> None:
|
|
219
|
+
"""Pop the stack through the nearest open `tag`, if there is one."""
|
|
220
|
+
for depth in range(len(self._stack) - 1, 0, -1):
|
|
221
|
+
if self._stack[depth].tag == tag:
|
|
222
|
+
del self._stack[depth:]
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
def handle_data(self, data: str) -> None:
|
|
226
|
+
if not data:
|
|
227
|
+
return
|
|
228
|
+
self._current.children.append(
|
|
229
|
+
Node(tag=TEXT, data=data, parent=self._current)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_html(html: str, *, xml: bool = False) -> Node:
|
|
234
|
+
"""Parse `html` into a tree, tolerating unclosed and stray tags.
|
|
235
|
+
|
|
236
|
+
Set `xml` for RSS and Atom, where no tag is void: an HTML parser would
|
|
237
|
+
treat `<link>https://...</link>` as an empty element and drop the URL.
|
|
238
|
+
"""
|
|
239
|
+
builder = _TreeBuilder(void_tags=set() if xml else VOID_TAGS)
|
|
240
|
+
try:
|
|
241
|
+
builder.feed(html)
|
|
242
|
+
builder.close()
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
return builder.root
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def page_title(root: Node) -> str:
|
|
249
|
+
"""The document title, preferring ``og:title`` over ``<title>``."""
|
|
250
|
+
for meta in root.select("meta"):
|
|
251
|
+
prop = meta.get("property").lower() or meta.get("name").lower()
|
|
252
|
+
if prop in {"og:title", "twitter:title"} and meta.get("content").strip():
|
|
253
|
+
return unescape(meta.get("content").strip())
|
|
254
|
+
title = root.select_one("title")
|
|
255
|
+
return title.text() if title is not None else ""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def page_description(root: Node) -> str:
|
|
259
|
+
"""The page's meta description, when it declares one."""
|
|
260
|
+
for meta in root.select("meta"):
|
|
261
|
+
prop = meta.get("property").lower() or meta.get("name").lower()
|
|
262
|
+
if prop in {"description", "og:description"} and meta.get("content").strip():
|
|
263
|
+
return unescape(meta.get("content").strip())
|
|
264
|
+
return ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _text_score(node: Node) -> int:
|
|
268
|
+
"""A crude density score: text length, penalised by link-heavy markup."""
|
|
269
|
+
text_len = len(node.text())
|
|
270
|
+
link_len = sum(len(a.text()) for a in node.select("a"))
|
|
271
|
+
return text_len - link_len
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main_content(root: Node) -> Node:
|
|
275
|
+
"""Pick the subtree that most likely holds the article body.
|
|
276
|
+
|
|
277
|
+
Semantic containers win outright when they carry real text. Otherwise the
|
|
278
|
+
densest `div` or `section` is chosen, which keeps navigation chrome and
|
|
279
|
+
link farms out of the Markdown without needing a full readability port.
|
|
280
|
+
"""
|
|
281
|
+
for selector in ("article", "main", "[role=main]"):
|
|
282
|
+
if selector.startswith("["):
|
|
283
|
+
candidates = [
|
|
284
|
+
n for n in root.descendants() if n.get("role") == "main"
|
|
285
|
+
]
|
|
286
|
+
else:
|
|
287
|
+
candidates = root.select(selector)
|
|
288
|
+
best = max(candidates, key=_text_score, default=None)
|
|
289
|
+
if best is not None and _text_score(best) > 200:
|
|
290
|
+
return best
|
|
291
|
+
|
|
292
|
+
hinted = [
|
|
293
|
+
n
|
|
294
|
+
for n in root.select("div, section")
|
|
295
|
+
if any(
|
|
296
|
+
hint in (n.get("id") + " " + n.get("class")).lower()
|
|
297
|
+
for hint in CONTAINER_HINTS
|
|
298
|
+
)
|
|
299
|
+
]
|
|
300
|
+
best = max(hinted, key=_text_score, default=None)
|
|
301
|
+
if best is not None and _text_score(best) > 400:
|
|
302
|
+
return best
|
|
303
|
+
|
|
304
|
+
body = root.select_one("body")
|
|
305
|
+
return body if body is not None else root
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _fence_for(code: str) -> str:
|
|
309
|
+
"""Return a fence long enough to survive backticks inside `code`."""
|
|
310
|
+
longest = max((len(run) for run in re.findall(r"`+", code)), default=0)
|
|
311
|
+
return "`" * max(3, longest + 1)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class _MarkdownWriter:
|
|
315
|
+
"""Renders a node tree to Markdown."""
|
|
316
|
+
|
|
317
|
+
def __init__(self, base_url: str = "", *, drop_boilerplate: bool = True) -> None:
|
|
318
|
+
self.base_url = base_url
|
|
319
|
+
self.drop_boilerplate = drop_boilerplate
|
|
320
|
+
self.parts: list[str] = []
|
|
321
|
+
|
|
322
|
+
def render(self, node: Node) -> str:
|
|
323
|
+
self._block(node)
|
|
324
|
+
text = "".join(self.parts)
|
|
325
|
+
text = "\n".join(line.rstrip() for line in text.split("\n"))
|
|
326
|
+
return _BLANK_RE.sub("\n\n", text).strip()
|
|
327
|
+
|
|
328
|
+
def _emit(self, text: str) -> None:
|
|
329
|
+
self.parts.append(text)
|
|
330
|
+
|
|
331
|
+
def _block(self, node: Node, list_depth: int = 0) -> None:
|
|
332
|
+
for child in node.children:
|
|
333
|
+
self._node(child, list_depth)
|
|
334
|
+
|
|
335
|
+
def _node(self, node: Node, list_depth: int) -> None:
|
|
336
|
+
tag = node.tag
|
|
337
|
+
if tag in SKIPPED_TAGS:
|
|
338
|
+
return
|
|
339
|
+
if self.drop_boilerplate and tag in BOILERPLATE_TAGS:
|
|
340
|
+
return
|
|
341
|
+
if tag == TEXT:
|
|
342
|
+
self._emit(_WS_RE.sub(" ", node.data.replace("\n", " ")))
|
|
343
|
+
return
|
|
344
|
+
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
|
345
|
+
heading = node.text().rstrip("¶#").rstrip()
|
|
346
|
+
if heading:
|
|
347
|
+
self._emit(f"\n\n{'#' * int(tag[1])} {heading}\n\n")
|
|
348
|
+
return
|
|
349
|
+
if tag == "br":
|
|
350
|
+
self._emit("\n")
|
|
351
|
+
return
|
|
352
|
+
if tag == "hr":
|
|
353
|
+
self._emit("\n\n---\n\n")
|
|
354
|
+
return
|
|
355
|
+
if tag == "pre":
|
|
356
|
+
code = node.select_one("code") or node
|
|
357
|
+
body = code.raw_text().strip("\n")
|
|
358
|
+
if body.strip():
|
|
359
|
+
fence = _fence_for(body)
|
|
360
|
+
self._emit(f"\n\n{fence}\n{body}\n{fence}\n\n")
|
|
361
|
+
return
|
|
362
|
+
if tag == "code":
|
|
363
|
+
body = node.text()
|
|
364
|
+
if body:
|
|
365
|
+
self._emit(f"`{body}`")
|
|
366
|
+
return
|
|
367
|
+
if tag in {"strong", "b"}:
|
|
368
|
+
self._inline(node, "**")
|
|
369
|
+
return
|
|
370
|
+
if tag in {"em", "i"}:
|
|
371
|
+
self._inline(node, "*")
|
|
372
|
+
return
|
|
373
|
+
if tag == "a":
|
|
374
|
+
self._anchor(node, list_depth)
|
|
375
|
+
return
|
|
376
|
+
if tag == "img":
|
|
377
|
+
alt = node.get("alt").strip()
|
|
378
|
+
if alt:
|
|
379
|
+
self._emit(f")})")
|
|
380
|
+
return
|
|
381
|
+
if tag in {"ul", "ol"}:
|
|
382
|
+
self._list(node, list_depth)
|
|
383
|
+
return
|
|
384
|
+
if tag == "blockquote":
|
|
385
|
+
quoted = _MarkdownWriter(self.base_url).render(node)
|
|
386
|
+
if quoted:
|
|
387
|
+
body = "\n".join("> " + line for line in quoted.split("\n"))
|
|
388
|
+
self._emit(f"\n\n{body}\n\n")
|
|
389
|
+
return
|
|
390
|
+
if tag == "table":
|
|
391
|
+
self._table(node)
|
|
392
|
+
return
|
|
393
|
+
if tag in BLOCK_TAGS:
|
|
394
|
+
self._emit("\n\n")
|
|
395
|
+
self._block(node, list_depth)
|
|
396
|
+
self._emit("\n\n")
|
|
397
|
+
return
|
|
398
|
+
self._block(node, list_depth)
|
|
399
|
+
|
|
400
|
+
def _inline(self, node: Node, marker: str) -> None:
|
|
401
|
+
body = node.text()
|
|
402
|
+
if body:
|
|
403
|
+
self._emit(f"{marker}{body}{marker}")
|
|
404
|
+
|
|
405
|
+
def _anchor(self, node: Node, list_depth: int) -> None:
|
|
406
|
+
label = node.text()
|
|
407
|
+
href = self._absolute(node.get("href"))
|
|
408
|
+
if not label:
|
|
409
|
+
self._block(node, list_depth)
|
|
410
|
+
return
|
|
411
|
+
if not href or href.startswith("javascript:"):
|
|
412
|
+
self._emit(label)
|
|
413
|
+
return
|
|
414
|
+
self._emit(f"[{label}]({href})")
|
|
415
|
+
|
|
416
|
+
def _list(self, node: Node, list_depth: int) -> None:
|
|
417
|
+
items = [c for c in node.children if c.tag == "li"]
|
|
418
|
+
if not items:
|
|
419
|
+
return
|
|
420
|
+
self._emit("\n\n")
|
|
421
|
+
indent = " " * list_depth
|
|
422
|
+
for index, item in enumerate(items, start=1):
|
|
423
|
+
bullet = f"{index}." if node.tag == "ol" else "-"
|
|
424
|
+
inner = _MarkdownWriter(self.base_url)
|
|
425
|
+
inner._block(item, list_depth + 1)
|
|
426
|
+
body = _BLANK_RE.sub("\n\n", "".join(inner.parts)).strip()
|
|
427
|
+
if not body:
|
|
428
|
+
continue
|
|
429
|
+
lines = body.split("\n")
|
|
430
|
+
self._emit(f"{indent}{bullet} {lines[0]}\n")
|
|
431
|
+
for line in lines[1:]:
|
|
432
|
+
self._emit(f"{indent} {line}\n")
|
|
433
|
+
self._emit("\n")
|
|
434
|
+
|
|
435
|
+
def _table(self, node: Node) -> None:
|
|
436
|
+
rows = node.select("tr")
|
|
437
|
+
if not rows:
|
|
438
|
+
return
|
|
439
|
+
rendered: list[list[str]] = []
|
|
440
|
+
for row in rows:
|
|
441
|
+
cells = [
|
|
442
|
+
c for c in row.children if c.tag in {"td", "th"}
|
|
443
|
+
] or row.select("td, th")
|
|
444
|
+
rendered.append([c.text().replace("|", "\\|") for c in cells])
|
|
445
|
+
rendered = [r for r in rendered if any(cell for cell in r)]
|
|
446
|
+
if not rendered:
|
|
447
|
+
return
|
|
448
|
+
width = max(len(r) for r in rendered)
|
|
449
|
+
lines = ["| " + " | ".join(r + [""] * (width - len(r))) + " |" for r in rendered]
|
|
450
|
+
separator = "| " + " | ".join(["---"] * width) + " |"
|
|
451
|
+
self._emit("\n\n" + "\n".join([lines[0], separator, *lines[1:]]) + "\n\n")
|
|
452
|
+
|
|
453
|
+
def _absolute(self, href: str) -> str:
|
|
454
|
+
"""Resolve `href` against the page URL, when both are usable."""
|
|
455
|
+
href = href.strip()
|
|
456
|
+
if not href or not self.base_url:
|
|
457
|
+
return href
|
|
458
|
+
from urllib.parse import urljoin
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
return urljoin(self.base_url, href)
|
|
462
|
+
except ValueError:
|
|
463
|
+
return href
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def html_to_markdown(html: str, *, base_url: str = "", article: bool = True) -> str:
|
|
467
|
+
"""Convert `html` to Markdown, optionally trimming to the article body.
|
|
468
|
+
|
|
469
|
+
Relative links and image sources are resolved against `base_url` so the
|
|
470
|
+
caller receives URLs it can pass straight back to `fetch`.
|
|
471
|
+
|
|
472
|
+
`article=False` keeps the whole body, and keeps the navigation, footers,
|
|
473
|
+
and asides inside it, because a caller asking for the whole page is
|
|
474
|
+
usually asking for exactly that chrome -- a documentation index or a link
|
|
475
|
+
list lives in a `<nav>`.
|
|
476
|
+
"""
|
|
477
|
+
root = parse_html(html)
|
|
478
|
+
node = main_content(root) if article else (root.select_one("body") or root)
|
|
479
|
+
return _MarkdownWriter(base_url, drop_boilerplate=article).render(node)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
__all__ = [
|
|
483
|
+
"Node",
|
|
484
|
+
"parse_html",
|
|
485
|
+
"html_to_markdown",
|
|
486
|
+
"main_content",
|
|
487
|
+
"page_title",
|
|
488
|
+
"page_description",
|
|
489
|
+
]
|