astree 0.1.0__tar.gz

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.
astree-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Outside Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
astree-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: astree
3
+ Version: 0.1.0
4
+ Summary: Dependency-free HTML trees and extraction powered by html.parser
5
+ Author: ellainng
6
+ Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.14
16
+ Project-URL: Homepage, https://github.com/wispsy/astree
17
+ Project-URL: Repository, https://github.com/wispsy/astree
18
+ Project-URL: Issues, https://github.com/wispsy/astree/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # astree
22
+
23
+ Dependency-free HTML tree parsing, CSS selector querying, and structured record extraction powered by Python's `html.parser`.
24
+
25
+ Requires Python 3.14+.
26
+
27
+ ## Highlights
28
+
29
+ - **Dependency-Free**: Built strictly on Python standard library primitives.
30
+ - **Full DOM Navigation**: Navigable `Document` and `Node` tree with parent/child links and attributes.
31
+ - **CSS Selectors**: Fast CSS selector queries (`select`, `select_one`).
32
+ - **Streaming Collectors**: Single-pass `LinkCollector` and `TextCollector` with bounds protection.
33
+ - **Declarative Schema Extraction**: `extract` and `extract_all` using `Field` definitions with type converters and cardinality validation.
34
+ - **Configurable Limits**: Protect against deeply nested tags or resource exhaustion (`max_depth`, `max_nodes`, `max_chars`).
35
+
36
+ ## Installation
37
+
38
+ ```sh
39
+ pip install astree
40
+ ```
41
+
42
+ For development:
43
+
44
+ ```sh
45
+ git clone https://github.com/wispsy/astree.git
46
+ cd astree
47
+ uv sync
48
+ ```
49
+
50
+ ## Quick Start
51
+
52
+ ### Basic Parsing & Queries
53
+
54
+ ```python
55
+ from astree import Document
56
+
57
+ doc = Document.from_html('''
58
+ <!doctype html>
59
+ <html>
60
+ <head><title>My Article</title></head>
61
+ <body>
62
+ <article class="card" data-id="42">
63
+ <h2>Headline</h2>
64
+ <a href="/post/42">Read story</a>
65
+ </article>
66
+ </body>
67
+ </html>
68
+ ''')
69
+
70
+ assert doc.title == 'My Article'
71
+ card = doc.select_one('article.card')
72
+ assert card is not None
73
+ assert card.attrs['data-id'] == '42'
74
+
75
+ link = card.find('a', href=True)
76
+ assert link is not None
77
+ assert link.attrs['href'] == '/post/42'
78
+ assert card.get_text(' ', strip=True) == 'Headline Read story'
79
+ ```
80
+
81
+ ### Structured Record Extraction
82
+
83
+ ```python
84
+ from astree import Document, Field, extract_all
85
+
86
+ records = extract_all(doc, 'article.card', {
87
+ 'id': Field(attribute='data-id', convert=int),
88
+ 'headline': Field('h2'),
89
+ 'url': Field('a', attribute='href'),
90
+ })
91
+
92
+ # [{'id': 42, 'headline': 'Headline', 'url': '/post/42'}]
93
+ ```
94
+
95
+ ## Documentation
96
+
97
+ See [docs/api.md](docs/api.md) for full API details, selector capabilities, collectors, and structured extraction specifications.
98
+
99
+ ## Running Tests
100
+
101
+ ```sh
102
+ python -m unittest discover -s tests -v
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT
astree-0.1.0/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # astree
2
+
3
+ Dependency-free HTML tree parsing, CSS selector querying, and structured record extraction powered by Python's `html.parser`.
4
+
5
+ Requires Python 3.14+.
6
+
7
+ ## Highlights
8
+
9
+ - **Dependency-Free**: Built strictly on Python standard library primitives.
10
+ - **Full DOM Navigation**: Navigable `Document` and `Node` tree with parent/child links and attributes.
11
+ - **CSS Selectors**: Fast CSS selector queries (`select`, `select_one`).
12
+ - **Streaming Collectors**: Single-pass `LinkCollector` and `TextCollector` with bounds protection.
13
+ - **Declarative Schema Extraction**: `extract` and `extract_all` using `Field` definitions with type converters and cardinality validation.
14
+ - **Configurable Limits**: Protect against deeply nested tags or resource exhaustion (`max_depth`, `max_nodes`, `max_chars`).
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ pip install astree
20
+ ```
21
+
22
+ For development:
23
+
24
+ ```sh
25
+ git clone https://github.com/wispsy/astree.git
26
+ cd astree
27
+ uv sync
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ### Basic Parsing & Queries
33
+
34
+ ```python
35
+ from astree import Document
36
+
37
+ doc = Document.from_html('''
38
+ <!doctype html>
39
+ <html>
40
+ <head><title>My Article</title></head>
41
+ <body>
42
+ <article class="card" data-id="42">
43
+ <h2>Headline</h2>
44
+ <a href="/post/42">Read story</a>
45
+ </article>
46
+ </body>
47
+ </html>
48
+ ''')
49
+
50
+ assert doc.title == 'My Article'
51
+ card = doc.select_one('article.card')
52
+ assert card is not None
53
+ assert card.attrs['data-id'] == '42'
54
+
55
+ link = card.find('a', href=True)
56
+ assert link is not None
57
+ assert link.attrs['href'] == '/post/42'
58
+ assert card.get_text(' ', strip=True) == 'Headline Read story'
59
+ ```
60
+
61
+ ### Structured Record Extraction
62
+
63
+ ```python
64
+ from astree import Document, Field, extract_all
65
+
66
+ records = extract_all(doc, 'article.card', {
67
+ 'id': Field(attribute='data-id', convert=int),
68
+ 'headline': Field('h2'),
69
+ 'url': Field('a', attribute='href'),
70
+ })
71
+
72
+ # [{'id': 42, 'headline': 'Headline', 'url': '/post/42'}]
73
+ ```
74
+
75
+ ## Documentation
76
+
77
+ See [docs/api.md](docs/api.md) for full API details, selector capabilities, collectors, and structured extraction specifications.
78
+
79
+ ## Running Tests
80
+
81
+ ```sh
82
+ python -m unittest discover -s tests -v
83
+ ```
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,92 @@
1
+ # astree API Reference
2
+
3
+ `astree` provides dependency-free HTML tree parsing, CSS selector queries, streaming collectors, and structured record extraction powered by Python's standard library `html.parser.HTMLParser`. Python 3.14+ is required.
4
+
5
+ ---
6
+
7
+ ## 1. Document & Node Model
8
+
9
+ ### `Document`
10
+
11
+ Constructed via:
12
+ - `Document.from_html(html: str, **limits) -> Document`: Convenience factory parsing the full HTML string.
13
+ - Or via `DocumentParser`.
14
+
15
+ #### Methods & Properties
16
+ - `title: str | None`: Returns the text of the `<title>` tag, if present.
17
+ - `text: str`: Normalized text across all descendants (excluding script/style/comments).
18
+ - `get_text(separator: str = '', strip: bool = False) -> str`: Concatenates text nodes with a separator and optional whitespace stripping.
19
+ - `select(selector: str) -> list[Node]`: Queries descendant elements using CSS selectors in document order.
20
+ - `select_one(selector: str) -> Node | None`: Returns the first matching element, or `None`.
21
+ - `find(tag: str | None = None, attrs: Mapping[str, Any] | None = None, **kwargs) -> Node | None`: Searches for matching descendant element.
22
+ - `find_all(tag: str | None = None, attrs: Mapping[str, Any] | None = None, **kwargs) -> list[Node]`: Finds all matching descendant elements.
23
+ - `descendants() -> Iterator[Node]`: Iterates over all descendant nodes in depth-first preorder.
24
+
25
+ ### `Node`
26
+
27
+ Represents a DOM node.
28
+ - `kind: Literal['root', 'element', 'text', 'comment', 'decl', 'pi']`
29
+ - `tag: str | None`: Tag name for elements (e.g. `'div'`, `'a'`).
30
+ - `attrs: Mapping[str, str]`: Read-only attributes dictionary.
31
+ - `parent: Node | None`: Parent node.
32
+ - `children: tuple[Node, ...]`: Direct child nodes.
33
+ - `data: str | None`: Text content for text/comment/decl nodes.
34
+
35
+ ---
36
+
37
+ ## 2. Streaming Parsers & Collectors
38
+
39
+ ### `DocumentParser`
40
+
41
+ Subclasses `html.parser.HTMLParser`. Incrementally feeds HTML chunks.
42
+
43
+ ```python
44
+ parser = DocumentParser(max_depth=512, max_nodes=50_000, max_chars=10_000_000)
45
+ parser.feed(chunk)
46
+ parser.close()
47
+ doc = parser.document
48
+ ```
49
+
50
+ ### `LinkCollector`
51
+
52
+ Streaming anchor tag extractor:
53
+ ```python
54
+ collector = LinkCollector(base_url='https://example.com/', collect=True, on_link=callback)
55
+ collector.feed(chunk)
56
+ collector.close()
57
+ for link in collector.links:
58
+ print(link.url, link.text)
59
+ ```
60
+
61
+ ### `TextCollector`
62
+
63
+ Streaming text extractor ignoring non-visible tags:
64
+ ```python
65
+ collector = TextCollector()
66
+ collector.feed(chunk)
67
+ collector.close()
68
+ print(collector.text)
69
+ ```
70
+
71
+ ---
72
+
73
+ ## 3. Structured Record Extraction
74
+
75
+ Extracts typed dictionaries or dataclass records from HTML elements:
76
+
77
+ ```python
78
+ from astree import Document, Field, extract_all
79
+
80
+ doc = Document.from_html(html)
81
+ records = extract_all(doc, 'article.item', {
82
+ 'id': Field(attribute='data-id', convert=int),
83
+ 'title': Field('h2'),
84
+ 'link': Field('a', attribute='href'),
85
+ 'tags': Field('span.tag', many=True),
86
+ })
87
+ ```
88
+
89
+ - `Field(selector=None, attribute=None, default=..., required=True, many=False, convert=None)`: Defines how a record field is extracted from a scoped element.
90
+ - `extract(node, schema, factory=dict)`: Extracts a single record from a node.
91
+ - `extract_all(node, row_selector, schema, factory=dict)`: Scopes and extracts records for all elements matching `row_selector`.
92
+ - `ExtractionError`: Raised when extraction fails due to missing required fields, cardinality mismatch, or conversion errors.
@@ -0,0 +1,21 @@
1
+ """Offline HTML querying and callback-based link extraction."""
2
+
3
+ from astree import Document, LinkCollector, TextCollector
4
+
5
+ source = '<title>Reading</title><article class=card><h2>A &amp; B</h2><a href="/one">Read</a></article>'
6
+ doc = Document.from_html(source)
7
+ assert doc.title == 'Reading'
8
+ assert doc.select_one('.card > h2').text == 'A & B'
9
+ assert doc.select_one('article').get_text(' ', strip=True) == 'A & B Read'
10
+ links = []
11
+ parser = LinkCollector(base_url='https://example.test/', collect=False, on_link=links.append)
12
+ for offset in range(0, len(source), 7):
13
+ parser.feed(source[offset:offset + 7])
14
+ parser.close()
15
+ assert links[0].url == 'https://example.test/one'
16
+ assert links[0].text == 'Read'
17
+ collector = TextCollector()
18
+ collector.feed('<p>Visible</p><style>hidden</style>')
19
+ collector.close()
20
+ assert collector.text == 'Visible'
21
+ print('HTML extraction passed')
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "astree"
3
+ version = "0.1.0"
4
+ description = "Dependency-free HTML trees and extraction powered by html.parser"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ dependencies = []
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [[project.authors]]
20
+ name = "ellainng"
21
+ email = "299958882+ellainng@users.noreply.github.com"
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/wispsy/astree"
25
+ Repository = "https://github.com/wispsy/astree"
26
+ Issues = "https://github.com/wispsy/astree/issues"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.13.6,<0.14.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.uv.build-backend]
33
+ source-include = [
34
+ "tests/**",
35
+ "docs/**",
36
+ "examples/**",
37
+ "scripts/**",
38
+ "LICENSE",
39
+ "README.md",
40
+ ]
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "astree"
3
+ version = "0.1.0"
4
+ description = "Dependency-free HTML trees and extraction powered by html.parser"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }
10
+ ]
11
+ requires-python = ">=3.14"
12
+ dependencies = []
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/wispsy/astree"
24
+ Repository = "https://github.com/wispsy/astree"
25
+ Issues = "https://github.com/wispsy/astree/issues"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.13.6,<0.14.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ source-include = ["tests/**", "docs/**", "examples/**", "scripts/**", "LICENSE", "README.md"]
@@ -0,0 +1,78 @@
1
+ """Bounded diagnostic measurements; no benchmark threshold or speed claim."""
2
+
3
+ import argparse
4
+ import gc
5
+ import json
6
+ import platform
7
+ import sys
8
+ from time import perf_counter
9
+ import tracemalloc
10
+
11
+ from astree import Document, Field, __version__, extract_all
12
+
13
+
14
+ def _measure(source: str) -> dict[str, object]:
15
+ tracemalloc.start()
16
+ try:
17
+ started = perf_counter()
18
+ document = Document.from_html(source)
19
+ records = extract_all(
20
+ document,
21
+ 'article',
22
+ {
23
+ 'id': Field(attribute='data-id', convert=int),
24
+ 'title': Field('h2'),
25
+ },
26
+ )
27
+ elapsed = perf_counter() - started
28
+ _, peak = tracemalloc.get_traced_memory()
29
+ return {'records': len(records), 'elapsed_seconds': elapsed, 'traced_peak_bytes': peak}
30
+ finally:
31
+ tracemalloc.stop()
32
+
33
+
34
+ def profile(records: int = 1000, iterations: int = 3) -> dict[str, object]:
35
+ for name, value, maximum in (('records', records, 10000), ('iterations', iterations, 20)):
36
+ if isinstance(value, bool) or not isinstance(value, int):
37
+ raise TypeError(f'{name} must be an integer')
38
+ if not 1 <= value <= maximum:
39
+ raise ValueError(f'{name} must be between 1 and {maximum}')
40
+ if tracemalloc.is_tracing():
41
+ raise RuntimeError('run this diagnostic in its own tracemalloc session')
42
+ source = ''.join(f'<article data-id="{n}"><h2>Entry {n}</h2></article>' for n in range(records))
43
+ samples = []
44
+ for _ in range(iterations):
45
+ gc.collect()
46
+ samples.append(_measure(source))
47
+ return {
48
+ 'environment': {
49
+ 'astree': __version__,
50
+ 'python': sys.version,
51
+ 'implementation': platform.python_implementation(),
52
+ 'platform': platform.platform(),
53
+ },
54
+ 'workload': {
55
+ 'records': records,
56
+ 'input_characters': len(source),
57
+ 'iterations': iterations,
58
+ 'measured_stage': 'parse, select and extract; excludes input generation/imports',
59
+ 'memory_scope': 'traced Python allocations, not process RSS',
60
+ },
61
+ 'samples': samples,
62
+ }
63
+
64
+
65
+ def main() -> None:
66
+ parser = argparse.ArgumentParser(description=__doc__)
67
+ parser.add_argument('--records', type=int, default=1000)
68
+ parser.add_argument('--iterations', type=int, default=3)
69
+ args = parser.parse_args()
70
+ try:
71
+ result = profile(args.records, args.iterations)
72
+ except (ValueError, TypeError, RuntimeError) as error:
73
+ parser.error(str(error))
74
+ print(json.dumps(result, indent=2))
75
+
76
+
77
+ if __name__ == '__main__':
78
+ main()
@@ -0,0 +1,25 @@
1
+ """Dependency-free HTML trees and extraction powered by html.parser."""
2
+
3
+ from .collectors import Link, LinkCollector, TextCollector
4
+ from .extract import ExtractionError, Field, extract, extract_all
5
+ from .nodes import Node
6
+ from .parser import Document, DocumentParser, ParseLimitError
7
+ from .selectors import SelectorSyntaxError
8
+
9
+ __version__ = '0.1.0'
10
+
11
+ __all__ = [
12
+ 'Document',
13
+ 'DocumentParser',
14
+ 'Node',
15
+ 'ParseLimitError',
16
+ 'SelectorSyntaxError',
17
+ 'Link',
18
+ 'LinkCollector',
19
+ 'TextCollector',
20
+ 'Field',
21
+ 'ExtractionError',
22
+ 'extract',
23
+ 'extract_all',
24
+ '__version__',
25
+ ]
@@ -0,0 +1,154 @@
1
+ """Incremental extraction without retaining an HTML document tree."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from urllib.parse import urljoin
6
+
7
+ from .parser import _BoundedParser, ParseLimitError, VOID_ELEMENTS
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Link:
12
+ """An anchor's original href, visible text, and explicitly resolved URL."""
13
+
14
+ href: str
15
+ text: str
16
+ url: str
17
+
18
+
19
+ @dataclass(eq=False, slots=True)
20
+ class _Frame:
21
+ tag: str
22
+ href: str | None = None
23
+ pieces: list[str] = field(default_factory=list)
24
+
25
+
26
+ class _StreamParser(_BoundedParser):
27
+ def __init__(self, *, max_chars: int, max_nodes: int, max_depth: int) -> None:
28
+ super().__init__(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
29
+ self._stack: list[_Frame] = []
30
+ self._last_was_data = False
31
+
32
+ def _open(self, tag: str, attrs: list[tuple[str, str | None]], *, closed: bool) -> None:
33
+ if len(self._stack) >= self.max_depth:
34
+ raise ParseLimitError('max_depth exceeded')
35
+ self._count()
36
+ self._last_was_data = False
37
+ frame = _Frame(tag, dict(attrs).get('href') if tag == 'a' else None)
38
+ if closed or tag in VOID_ELEMENTS:
39
+ self._end(frame)
40
+ else:
41
+ self._stack.append(frame)
42
+
43
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
44
+ self._open(tag, attrs, closed=False)
45
+
46
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
47
+ self._open(tag, attrs, closed=True)
48
+
49
+ def handle_endtag(self, tag: str) -> None:
50
+ for index in range(len(self._stack) - 1, -1, -1):
51
+ if self._stack[index].tag == tag:
52
+ frames = self._stack[index:]
53
+ del self._stack[index:]
54
+ for frame in reversed(frames):
55
+ self._end(frame)
56
+ self._last_was_data = False
57
+ break
58
+
59
+ def handle_data(self, data: str) -> None:
60
+ if not data:
61
+ return
62
+ if not self._last_was_data:
63
+ self._count()
64
+ self._last_was_data = True
65
+ if not any(frame.tag in {'script', 'style'} for frame in self._stack):
66
+ self._data(data)
67
+
68
+ def handle_comment(self, data: str) -> None:
69
+ self._count()
70
+ self._last_was_data = False
71
+
72
+ handle_decl = handle_comment
73
+ handle_pi = handle_comment
74
+ unknown_decl = handle_comment
75
+
76
+ def _data(self, data: str) -> None:
77
+ pass
78
+
79
+ def _end(self, frame: _Frame) -> None:
80
+ pass
81
+
82
+ def _finish(self) -> None:
83
+ frames, self._stack = self._stack, []
84
+ for frame in reversed(frames):
85
+ self._end(frame)
86
+
87
+
88
+ class LinkCollector(_StreamParser):
89
+ """Emit links when anchors close, including implicit closure at EOF.
90
+
91
+ Set collect=False with on_link to process output without retaining results.
92
+ URL resolution does not validate schemes or make URLs safe to fetch.
93
+ """
94
+
95
+ def __init__(self, *, base_url: str | None = None,
96
+ on_link: Callable[[Link], None] | None = None, collect: bool = True,
97
+ max_chars: int = 10_000_000, max_nodes: int = 100_000,
98
+ max_depth: int = 256) -> None:
99
+ if base_url is not None and not isinstance(base_url, str):
100
+ raise TypeError('base_url must be a string or None')
101
+ if on_link is not None and not callable(on_link):
102
+ raise TypeError('on_link must be callable or None')
103
+ if not isinstance(collect, bool):
104
+ raise TypeError('collect must be a bool')
105
+ super().__init__(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
106
+ self._base_url = base_url
107
+ self._on_link = on_link
108
+ self._collect = collect
109
+ self._links: list[Link] = []
110
+
111
+ @property
112
+ def links(self) -> tuple[Link, ...]:
113
+ return tuple(self._links)
114
+
115
+ def _data(self, data: str) -> None:
116
+ for frame in self._stack:
117
+ if frame.href is not None:
118
+ frame.pieces.append(data)
119
+
120
+ def _end(self, frame: _Frame) -> None:
121
+ if frame.href is None:
122
+ return
123
+ url = urljoin(self._base_url, frame.href) if self._base_url is not None else frame.href
124
+ link = Link(frame.href, ''.join(frame.pieces), url)
125
+ if self._collect:
126
+ self._links.append(link)
127
+ if self._on_link is not None:
128
+ self._on_link(link)
129
+
130
+
131
+ class TextCollector(_StreamParser):
132
+ """Collect visible text or call on_text with incremental decoded chunks."""
133
+
134
+ def __init__(self, *, on_text: Callable[[str], None] | None = None,
135
+ collect: bool = True, max_chars: int = 10_000_000,
136
+ max_nodes: int = 100_000, max_depth: int = 256) -> None:
137
+ if on_text is not None and not callable(on_text):
138
+ raise TypeError('on_text must be callable or None')
139
+ if not isinstance(collect, bool):
140
+ raise TypeError('collect must be a bool')
141
+ super().__init__(max_chars=max_chars, max_nodes=max_nodes, max_depth=max_depth)
142
+ self._on_text = on_text
143
+ self._collect = collect
144
+ self._pieces: list[str] = []
145
+
146
+ @property
147
+ def text(self) -> str:
148
+ return ''.join(self._pieces)
149
+
150
+ def _data(self, data: str) -> None:
151
+ if self._collect:
152
+ self._pieces.append(data)
153
+ if self._on_text is not None:
154
+ self._on_text(data)