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/_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"![{alt}]({self._absolute(node.get('src'))})")
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
+ ]