ergane 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.
- ergane-0.1.0/.github/workflows/publish.yml +70 -0
- ergane-0.1.0/.github/workflows/test.yml +47 -0
- ergane-0.1.0/.gitignore +11 -0
- ergane-0.1.0/CHANGELOG.md +26 -0
- ergane-0.1.0/LICENSE +21 -0
- ergane-0.1.0/PKG-INFO +143 -0
- ergane-0.1.0/README.md +107 -0
- ergane-0.1.0/benchmarks/parse_benchmark.py +318 -0
- ergane-0.1.0/pyproject.toml +71 -0
- ergane-0.1.0/src/__init__.py +1 -0
- ergane-0.1.0/src/crawler/__init__.py +6 -0
- ergane-0.1.0/src/crawler/fetcher.py +149 -0
- ergane-0.1.0/src/crawler/parser.py +152 -0
- ergane-0.1.0/src/crawler/pipeline.py +124 -0
- ergane-0.1.0/src/crawler/scheduler.py +98 -0
- ergane-0.1.0/src/main.py +184 -0
- ergane-0.1.0/src/models/__init__.py +3 -0
- ergane-0.1.0/src/models/schemas.py +64 -0
- ergane-0.1.0/tests/__init__.py +1 -0
- ergane-0.1.0/tests/conftest.py +96 -0
- ergane-0.1.0/tests/test_fetcher.py +187 -0
- ergane-0.1.0/tests/test_parser.py +280 -0
- ergane-0.1.0/tests/test_pipeline.py +310 -0
- ergane-0.1.0/tests/test_scheduler.py +185 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Python
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Install build dependencies
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade pip
|
|
26
|
+
pip install build twine
|
|
27
|
+
|
|
28
|
+
- name: Build package
|
|
29
|
+
run: python -m build
|
|
30
|
+
|
|
31
|
+
- name: Check package
|
|
32
|
+
run: twine check dist/*
|
|
33
|
+
|
|
34
|
+
- name: Upload build artifacts
|
|
35
|
+
uses: actions/upload-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: dist
|
|
38
|
+
path: dist/
|
|
39
|
+
|
|
40
|
+
publish-testpypi:
|
|
41
|
+
needs: build
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
environment: testpypi
|
|
44
|
+
|
|
45
|
+
steps:
|
|
46
|
+
- name: Download build artifacts
|
|
47
|
+
uses: actions/download-artifact@v4
|
|
48
|
+
with:
|
|
49
|
+
name: dist
|
|
50
|
+
path: dist/
|
|
51
|
+
|
|
52
|
+
- name: Publish to TestPyPI
|
|
53
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
54
|
+
with:
|
|
55
|
+
repository-url: https://test.pypi.org/legacy/
|
|
56
|
+
|
|
57
|
+
publish-pypi:
|
|
58
|
+
needs: build
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
environment: pypi
|
|
61
|
+
|
|
62
|
+
steps:
|
|
63
|
+
- name: Download build artifacts
|
|
64
|
+
uses: actions/download-artifact@v4
|
|
65
|
+
with:
|
|
66
|
+
name: dist
|
|
67
|
+
path: dist/
|
|
68
|
+
|
|
69
|
+
- name: Publish to PyPI
|
|
70
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
21
|
+
uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade pip
|
|
28
|
+
pip install -e .[dev]
|
|
29
|
+
|
|
30
|
+
- name: Run ruff linter
|
|
31
|
+
run: ruff check src/ tests/
|
|
32
|
+
|
|
33
|
+
- name: Run ruff formatter check
|
|
34
|
+
run: ruff format --check src/ tests/
|
|
35
|
+
|
|
36
|
+
- name: Run mypy
|
|
37
|
+
run: mypy src/
|
|
38
|
+
|
|
39
|
+
- name: Run tests with coverage
|
|
40
|
+
run: pytest tests/ -v --cov=src --cov-report=term-missing --cov-report=xml
|
|
41
|
+
|
|
42
|
+
- name: Upload coverage to Codecov
|
|
43
|
+
uses: codecov/codecov-action@v4
|
|
44
|
+
if: matrix.python-version == '3.12'
|
|
45
|
+
with:
|
|
46
|
+
files: ./coverage.xml
|
|
47
|
+
fail_ci_if_error: false
|
ergane-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2025-01-24
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release of Ergane web crawler
|
|
13
|
+
- Async HTTP/2 client using httpx for fast concurrent connections
|
|
14
|
+
- Per-domain rate limiting with token bucket algorithm
|
|
15
|
+
- Exponential backoff retry logic (max 3 attempts)
|
|
16
|
+
- robots.txt compliance (enabled by default)
|
|
17
|
+
- Fast HTML parsing with selectolax (16x faster than BeautifulSoup)
|
|
18
|
+
- Priority queue scheduler with URL deduplication
|
|
19
|
+
- Parquet output format via polars for efficient storage
|
|
20
|
+
- Graceful shutdown handling for SIGINT/SIGTERM
|
|
21
|
+
- CLI interface with configurable options:
|
|
22
|
+
- Multiple start URLs support
|
|
23
|
+
- Configurable concurrency and rate limits
|
|
24
|
+
- Depth limiting
|
|
25
|
+
- Same-domain or cross-domain crawling
|
|
26
|
+
- Custom output paths
|
ergane-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 pyamin1878
|
|
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.
|
ergane-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ergane
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-performance async web scraper with selectolax parsing
|
|
5
|
+
Project-URL: Homepage, https://github.com/pyamin1878/ergane
|
|
6
|
+
Project-URL: Repository, https://github.com/pyamin1878/ergane
|
|
7
|
+
Project-URL: Issues, https://github.com/pyamin1878/ergane/issues
|
|
8
|
+
Author: pyamin1878
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: async,crawler,httpx,selectolax,spider,web-scraper
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: click>=8.1.0
|
|
23
|
+
Requires-Dist: httpx[http2]>=0.27.0
|
|
24
|
+
Requires-Dist: polars>=1.0.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: selectolax>=0.3.21
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: beautifulsoup4>=4.12.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: lxml>=5.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.2.0; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# Ergane
|
|
38
|
+
|
|
39
|
+
[](https://badge.fury.io/py/ergane)
|
|
40
|
+
[](https://opensource.org/licenses/MIT)
|
|
41
|
+
[](https://www.python.org/downloads/)
|
|
42
|
+
|
|
43
|
+
High-performance async web scraper with HTTP/2 support, built with Python.
|
|
44
|
+
|
|
45
|
+
*Named after Ergane, Athena's title as goddess of crafts and weaving in Greek mythology.*
|
|
46
|
+
|
|
47
|
+
## Features
|
|
48
|
+
|
|
49
|
+
- **HTTP/2 Support** - Fast concurrent connections via httpx
|
|
50
|
+
- **Rate Limiting** - Per-domain token bucket throttling
|
|
51
|
+
- **Retry Logic** - Exponential backoff (max 3 attempts)
|
|
52
|
+
- **robots.txt Compliance** - Respects crawler directives by default
|
|
53
|
+
- **Fast HTML Parsing** - Selectolax with CSS selector extraction (16x faster than BeautifulSoup)
|
|
54
|
+
- **Smart Scheduling** - Priority queue with URL deduplication
|
|
55
|
+
- **Parquet Output** - Efficient columnar storage via polars
|
|
56
|
+
- **Graceful Shutdown** - Clean termination on SIGINT/SIGTERM
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install ergane
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For development:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install ergane[dev]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quick Start
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Crawl a single site
|
|
74
|
+
ergane -u https://example.com -n 100
|
|
75
|
+
|
|
76
|
+
# Crawl multiple start URLs
|
|
77
|
+
ergane -u https://site1.com -u https://site2.com -n 500
|
|
78
|
+
|
|
79
|
+
# Custom output and settings
|
|
80
|
+
ergane -u https://docs.python.org -n 50 -c 20 -r 5 -o python_docs.parquet
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## CLI Options
|
|
84
|
+
|
|
85
|
+
| Option | Short | Default | Description |
|
|
86
|
+
|--------|-------|---------|-------------|
|
|
87
|
+
| `--url` | `-u` | required | Start URL(s), can specify multiple |
|
|
88
|
+
| `--output` | `-o` | `output.parquet` | Output file path |
|
|
89
|
+
| `--max-pages` | `-n` | `100` | Maximum pages to crawl |
|
|
90
|
+
| `--max-depth` | `-d` | `3` | Maximum crawl depth from start URLs |
|
|
91
|
+
| `--concurrency` | `-c` | `10` | Concurrent requests |
|
|
92
|
+
| `--rate-limit` | `-r` | `10.0` | Requests per second per domain |
|
|
93
|
+
| `--timeout` | `-t` | `30.0` | Request timeout in seconds |
|
|
94
|
+
| `--same-domain` | | `true` | Stay on same domain as start URLs |
|
|
95
|
+
| `--any-domain` | | `false` | Follow links to any domain |
|
|
96
|
+
| `--ignore-robots` | | `false` | Ignore robots.txt restrictions |
|
|
97
|
+
|
|
98
|
+
## Output Format
|
|
99
|
+
|
|
100
|
+
Results are saved as a Parquet file with the following schema:
|
|
101
|
+
|
|
102
|
+
| Column | Type | Description |
|
|
103
|
+
|--------|------|-------------|
|
|
104
|
+
| `url` | string | Page URL |
|
|
105
|
+
| `title` | string | Page title |
|
|
106
|
+
| `text` | string | Extracted text content (max 10k chars) |
|
|
107
|
+
| `links` | string | JSON array of extracted links |
|
|
108
|
+
| `extracted_data` | string | JSON object of custom extractions |
|
|
109
|
+
| `crawled_at` | string | ISO timestamp |
|
|
110
|
+
|
|
111
|
+
Read results with polars:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import polars as pl
|
|
115
|
+
|
|
116
|
+
df = pl.read_parquet("output.parquet")
|
|
117
|
+
print(df.head())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Benchmarks
|
|
121
|
+
|
|
122
|
+
Ergane uses selectolax for HTML parsing, which is significantly faster than BeautifulSoup:
|
|
123
|
+
|
|
124
|
+
| Operation | Selectolax | BS4 + lxml | Speedup |
|
|
125
|
+
|-------------------|------------|------------|---------|
|
|
126
|
+
| Parse (small) | 0.05ms | 0.11ms | 2.0x |
|
|
127
|
+
| Parse (large) | 0.19ms | 6.05ms | 31.1x |
|
|
128
|
+
| Extract title | 0.20ms | 6.06ms | 30.7x |
|
|
129
|
+
| Extract links | 0.25ms | 6.73ms | 27.3x |
|
|
130
|
+
| Extract text | 0.29ms | 7.03ms | 24.5x |
|
|
131
|
+
| CSS selector | 0.20ms | 7.25ms | 35.7x |
|
|
132
|
+
|
|
133
|
+
**Average: 16x faster** (1000 iterations, 34KB HTML)
|
|
134
|
+
|
|
135
|
+
Run the benchmark:
|
|
136
|
+
```bash
|
|
137
|
+
pip install beautifulsoup4 lxml
|
|
138
|
+
python benchmarks/parse_benchmark.py
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
ergane-0.1.0/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Ergane
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/py/ergane)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
|
|
7
|
+
High-performance async web scraper with HTTP/2 support, built with Python.
|
|
8
|
+
|
|
9
|
+
*Named after Ergane, Athena's title as goddess of crafts and weaving in Greek mythology.*
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **HTTP/2 Support** - Fast concurrent connections via httpx
|
|
14
|
+
- **Rate Limiting** - Per-domain token bucket throttling
|
|
15
|
+
- **Retry Logic** - Exponential backoff (max 3 attempts)
|
|
16
|
+
- **robots.txt Compliance** - Respects crawler directives by default
|
|
17
|
+
- **Fast HTML Parsing** - Selectolax with CSS selector extraction (16x faster than BeautifulSoup)
|
|
18
|
+
- **Smart Scheduling** - Priority queue with URL deduplication
|
|
19
|
+
- **Parquet Output** - Efficient columnar storage via polars
|
|
20
|
+
- **Graceful Shutdown** - Clean termination on SIGINT/SIGTERM
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ergane
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For development:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install ergane[dev]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Crawl a single site
|
|
38
|
+
ergane -u https://example.com -n 100
|
|
39
|
+
|
|
40
|
+
# Crawl multiple start URLs
|
|
41
|
+
ergane -u https://site1.com -u https://site2.com -n 500
|
|
42
|
+
|
|
43
|
+
# Custom output and settings
|
|
44
|
+
ergane -u https://docs.python.org -n 50 -c 20 -r 5 -o python_docs.parquet
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## CLI Options
|
|
48
|
+
|
|
49
|
+
| Option | Short | Default | Description |
|
|
50
|
+
|--------|-------|---------|-------------|
|
|
51
|
+
| `--url` | `-u` | required | Start URL(s), can specify multiple |
|
|
52
|
+
| `--output` | `-o` | `output.parquet` | Output file path |
|
|
53
|
+
| `--max-pages` | `-n` | `100` | Maximum pages to crawl |
|
|
54
|
+
| `--max-depth` | `-d` | `3` | Maximum crawl depth from start URLs |
|
|
55
|
+
| `--concurrency` | `-c` | `10` | Concurrent requests |
|
|
56
|
+
| `--rate-limit` | `-r` | `10.0` | Requests per second per domain |
|
|
57
|
+
| `--timeout` | `-t` | `30.0` | Request timeout in seconds |
|
|
58
|
+
| `--same-domain` | | `true` | Stay on same domain as start URLs |
|
|
59
|
+
| `--any-domain` | | `false` | Follow links to any domain |
|
|
60
|
+
| `--ignore-robots` | | `false` | Ignore robots.txt restrictions |
|
|
61
|
+
|
|
62
|
+
## Output Format
|
|
63
|
+
|
|
64
|
+
Results are saved as a Parquet file with the following schema:
|
|
65
|
+
|
|
66
|
+
| Column | Type | Description |
|
|
67
|
+
|--------|------|-------------|
|
|
68
|
+
| `url` | string | Page URL |
|
|
69
|
+
| `title` | string | Page title |
|
|
70
|
+
| `text` | string | Extracted text content (max 10k chars) |
|
|
71
|
+
| `links` | string | JSON array of extracted links |
|
|
72
|
+
| `extracted_data` | string | JSON object of custom extractions |
|
|
73
|
+
| `crawled_at` | string | ISO timestamp |
|
|
74
|
+
|
|
75
|
+
Read results with polars:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import polars as pl
|
|
79
|
+
|
|
80
|
+
df = pl.read_parquet("output.parquet")
|
|
81
|
+
print(df.head())
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Benchmarks
|
|
85
|
+
|
|
86
|
+
Ergane uses selectolax for HTML parsing, which is significantly faster than BeautifulSoup:
|
|
87
|
+
|
|
88
|
+
| Operation | Selectolax | BS4 + lxml | Speedup |
|
|
89
|
+
|-------------------|------------|------------|---------|
|
|
90
|
+
| Parse (small) | 0.05ms | 0.11ms | 2.0x |
|
|
91
|
+
| Parse (large) | 0.19ms | 6.05ms | 31.1x |
|
|
92
|
+
| Extract title | 0.20ms | 6.06ms | 30.7x |
|
|
93
|
+
| Extract links | 0.25ms | 6.73ms | 27.3x |
|
|
94
|
+
| Extract text | 0.29ms | 7.03ms | 24.5x |
|
|
95
|
+
| CSS selector | 0.20ms | 7.25ms | 35.7x |
|
|
96
|
+
|
|
97
|
+
**Average: 16x faster** (1000 iterations, 34KB HTML)
|
|
98
|
+
|
|
99
|
+
Run the benchmark:
|
|
100
|
+
```bash
|
|
101
|
+
pip install beautifulsoup4 lxml
|
|
102
|
+
python benchmarks/parse_benchmark.py
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|