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 +21 -0
- astree-0.1.0/PKG-INFO +107 -0
- astree-0.1.0/README.md +87 -0
- astree-0.1.0/docs/api.md +92 -0
- astree-0.1.0/examples/html_extract.py +21 -0
- astree-0.1.0/pyproject.toml +40 -0
- astree-0.1.0/pyproject.toml.orig +32 -0
- astree-0.1.0/scripts/profile_extraction.py +78 -0
- astree-0.1.0/src/astree/__init__.py +25 -0
- astree-0.1.0/src/astree/collectors.py +154 -0
- astree-0.1.0/src/astree/extract.py +138 -0
- astree-0.1.0/src/astree/nodes.py +145 -0
- astree-0.1.0/src/astree/parser.py +158 -0
- astree-0.1.0/src/astree/py.typed +0 -0
- astree-0.1.0/src/astree/selectors.py +172 -0
- astree-0.1.0/tests/fixtures/html-corpus.json +14 -0
- astree-0.1.0/tests/test_corpus.py +97 -0
- astree-0.1.0/tests/test_html_collectors.py +79 -0
- astree-0.1.0/tests/test_html_extract.py +97 -0
- astree-0.1.0/tests/test_html_queries.py +47 -0
- astree-0.1.0/tests/test_html_tree.py +109 -0
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
|
astree-0.1.0/docs/api.md
ADDED
|
@@ -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 & 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)
|