Add intra-mart documentation crawler
Playwright-based crawler that renders pages from api.intra-mart.jp and document.intra-mart.jp, extracts main content, and converts to Markdown via Pandoc. SQLite-backed queue drives a resumable sequential pipeline across crawler, extractor, converter, and storage modules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
982391c0f0
commit
eb46e739e7
107
.gitignore
vendored
107
.gitignore
vendored
|
|
@ -1,7 +1,6 @@
|
|||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
|
|
@ -47,7 +46,8 @@ htmlcov/
|
|||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
*.py.cover
|
||||
*.lcov
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
|
@ -93,35 +93,65 @@ ipython_config.py
|
|||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi/*
|
||||
!.pixi/config.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat-schedule*
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
|
@ -158,31 +188,44 @@ cython_debug/
|
|||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
# .idea/
|
||||
|
||||
# ---> Java
|
||||
# Compiled class file
|
||||
*.class
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
# Temporary file for partial code execution
|
||||
tempCodeRunnerFile.py
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
# Output directories
|
||||
output/
|
||||
data/
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/
|
||||
|
||||
# Log archives
|
||||
logs/*.zip
|
||||
|
||||
|
|
|
|||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.11
|
||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
Documentation crawler that renders pages from `api.intra-mart.jp` and `document.intra-mart.jp` with Playwright, extracts the main content, and converts it to Markdown via Pandoc. Output is structured for Obsidian / MkDocs / Docusaurus / RAG ingestion.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install (Python >= 3.11)
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
# pandoc must be on PATH (apt install pandoc / https://pandoc.org/installing.html)
|
||||
|
||||
# Run the crawler
|
||||
python main.py
|
||||
|
||||
# Lint / format / typecheck / test (dev extras)
|
||||
pip install -e ".[dev]"
|
||||
ruff check .
|
||||
black .
|
||||
mypy .
|
||||
pytest # asyncio_mode=auto is preset
|
||||
pytest tests/test_x.py::test_name # single test
|
||||
```
|
||||
|
||||
Re-running `python main.py` resumes from the SQLite queue — finished URLs are skipped, `processing` rows are reset to `retry` on startup.
|
||||
|
||||
## Architecture
|
||||
|
||||
The pipeline lives in `main.py` as a single `DocumentationCrawler` class that orchestrates per-module components. Flow per page:
|
||||
|
||||
```
|
||||
URLManager.normalize/is_allowed
|
||||
↓
|
||||
PlaywrightClient.fetch_page (Chromium, JS-rendered, retries via tenacity)
|
||||
↓
|
||||
HTMLCleaner.clean (drop REMOVE_SELECTORS)
|
||||
↓
|
||||
HTMLExtractor.extract_main_content (first match of CONTENT_SELECTORS)
|
||||
↓
|
||||
AssetDownloader (download + rewrite asset links to local paths)
|
||||
↓
|
||||
MarkdownConverter.html_to_markdown (Pandoc → GFM, with YAML frontmatter)
|
||||
↓
|
||||
FileWriter / PathMapper (output/markdown/<domain>/<sanitized path>.md)
|
||||
↓
|
||||
extract_links + SphinxDiscovery + NavigationParser → enqueue new URLs
|
||||
```
|
||||
|
||||
### State is in SQLite, not in memory
|
||||
|
||||
`storage/metadata_db.py` (`data/metadata.db`) is the source of truth. Tables:
|
||||
|
||||
- `crawl_queue` — drives the loop (`pending` / `processing` / `retry` / `failed` / `done`); `get_next_pending_url` orders by `priority DESC, depth ASC, discovered_at ASC`.
|
||||
- `pages` — successful crawl metadata + markdown output path.
|
||||
- `assets`, `page_links`, `discovery_sources`, `failures` — provenance and the site graph.
|
||||
|
||||
The crawl loop is **sequential single-worker** even though README mentions concurrency — there is no asyncio worker pool. `BROWSER_RECYCLE_EVERY = 500` restarts Playwright periodically to bound memory.
|
||||
|
||||
### Discovery is multi-source
|
||||
|
||||
Links are discovered three ways and merged before enqueue: (1) generic `<a href>` extraction in `DocumentationCrawler.extract_links` (also targets Sphinx-specific selectors like `a.reference.internal`, `.toctree-wrapper a`, `link[rel=next/prev/up]`), (2) `SphinxDiscovery` (probes `searchindex.js`, `genindex.html`, etc.), (3) `NavigationParser` (builds a nav tree for `SUMMARY.md` / `navigation_tree.json` export).
|
||||
|
||||
### URL → filesystem mapping
|
||||
|
||||
`storage/path_mapper.py` produces `<output_dir>/<domain>/<sanitized path>.md`. Sanitizes Windows-invalid chars + reserved names, hashes query strings into a `__<hash>` suffix, and shortens names > 180 chars with an MD5 tail. Asset link rewriting in `extractor/asset_downloader.py` computes paths *relative to the current page's output path*, so don't change `PathMapper` output layout without also revisiting `rewrite_asset_links`.
|
||||
|
||||
### Configuration
|
||||
|
||||
`config.yaml` exists but **`main.py` does not read it** — the live configuration is the module-level constants at the top of `main.py` (`START_URLS`, `ALLOWED_DOMAINS`, `REMOVE_SELECTORS`, `CONTENT_SELECTORS`, `MAX_PAGES_PER_RUN`, `BROWSER_RECYCLE_EVERY`, `MAX_ATTEMPTS`). Update those, not the YAML.
|
||||
|
||||
### Logging
|
||||
|
||||
`utils/logger.py` configures loguru on import (just `from utils.logger import logger`). Writes rotating logs to `logs/crawler.log`, `logs/errors.log`, `logs/debug.log` — no extra setup needed.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Line length 79 (`black` and `ruff` both pinned to this). Target Python 3.11.
|
||||
- `pytest-asyncio` runs in `auto` mode — async test functions need no decorator.
|
||||
- Output dirs (`output/`, `data/`, `logs/`) are runtime artifacts; `output/assets/`, `output/markdown/`, `data/` are gitignored.
|
||||
210
LICENSE
210
LICENSE
|
|
@ -1,73 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 duyd
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
|
|||
373
README.md
373
README.md
|
|
@ -1,6 +1,373 @@
|
|||
# crawler-intra-mart
|
||||
# Intra-Mart Documentation Crawler → Markdown Exporter
|
||||
|
||||
Xây dựng một hệ thống crawl tài liệu từ:
|
||||
## 🚀 Quickstart với [`uv`](https://docs.astral.sh/uv/) (khuyến nghị)
|
||||
|
||||
* [https://api.intra-mart.jp/iap/index.html](https://api.intra-mart.jp/iap/index.html)
|
||||
Project đã được lock vào `uv.lock` + ghim Python qua `.python-version`. `uv` sẽ tự cài đúng phiên bản Python, dựng `.venv`, và sync deps từ lock file — không cần `pip`, không cần `pyenv`.
|
||||
|
||||
### 1. Cài `uv`
|
||||
|
||||
| OS | Lệnh |
|
||||
| ------------------- | --------------------------------------------------------------------------------- |
|
||||
| **macOS / Linux** | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| **Windows (PowerShell)** | `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 \| iex"` |
|
||||
| **macOS (Homebrew)** | `brew install uv` |
|
||||
| **Bất kỳ OS nào có pipx** | `pipx install uv` |
|
||||
|
||||
Xác nhận đã cài: `uv --version`.
|
||||
|
||||
### 2. Cài Pandoc (bắt buộc, không nằm trong `uv`)
|
||||
|
||||
`MarkdownConverter` gọi binary `pandoc` qua subprocess, nên Pandoc phải có sẵn trên `PATH`.
|
||||
|
||||
| OS | Lệnh |
|
||||
| ------------------------ | --------------------------------------------------- |
|
||||
| **macOS (Homebrew)** | `brew install pandoc` |
|
||||
| **Ubuntu / Debian** | `sudo apt install pandoc` |
|
||||
| **Fedora / RHEL** | `sudo dnf install pandoc` |
|
||||
| **Arch** | `sudo pacman -S pandoc` |
|
||||
| **Windows (winget)** | `winget install --id JohnMacFarlane.Pandoc` |
|
||||
| **Windows (Chocolatey)** | `choco install pandoc` |
|
||||
| **Khác** | Tải installer tại <https://pandoc.org/installing.html> |
|
||||
|
||||
Xác nhận: `pandoc --version`.
|
||||
|
||||
### 3. Clone & sync deps
|
||||
|
||||
```bash
|
||||
git clone https://github.com/IricsDo/intra_mart_doc_crawler.git
|
||||
cd intra_mart_doc_crawler
|
||||
|
||||
uv sync # tạo .venv, cài đúng Python 3.11 + toàn bộ deps từ uv.lock
|
||||
uv run playwright install chromium # tải Chromium cho Playwright
|
||||
```
|
||||
|
||||
> Trên **Linux**, có thể `playwright` báo thiếu system lib (libnss3, libatk, …). Fix:
|
||||
> `uv run playwright install-deps chromium` (cần sudo, dùng apt). Trên **macOS/Windows** thường không cần.
|
||||
|
||||
### 4. Chạy crawler
|
||||
|
||||
```bash
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
Hoặc activate venv rồi chạy như Python thường:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
source .venv/bin/activate
|
||||
python main.py
|
||||
|
||||
# Windows (PowerShell)
|
||||
.venv\Scripts\Activate.ps1
|
||||
python main.py
|
||||
|
||||
# Windows (cmd.exe)
|
||||
.venv\Scripts\activate.bat
|
||||
python main.py
|
||||
```
|
||||
|
||||
Chạy lại = **resume** từ SQLite queue (`data/metadata.db`). URL đã `done` bị skip.
|
||||
|
||||
### 5. Dev tools (lint / format / type-check / test)
|
||||
|
||||
```bash
|
||||
uv sync --extra dev # cài thêm black, ruff, mypy, pytest
|
||||
|
||||
uv run ruff check .
|
||||
uv run black .
|
||||
uv run mypy .
|
||||
uv run pytest # asyncio_mode = auto đã preset trong pyproject.toml
|
||||
```
|
||||
|
||||
### Lệnh `uv` thường dùng
|
||||
|
||||
| Tác dụng | Lệnh |
|
||||
| ------------------------------------- | ------------------------------------- |
|
||||
| Sync env đúng theo `uv.lock` | `uv sync` |
|
||||
| Sync + cài optional dep `dev` | `uv sync --extra dev` |
|
||||
| Thêm 1 package mới | `uv add <package>` |
|
||||
| Bỏ 1 package | `uv remove <package>` |
|
||||
| Update lock theo `pyproject.toml` | `uv lock` |
|
||||
| Upgrade 1 package | `uv lock --upgrade-package <package>` |
|
||||
| Chạy lệnh trong venv mà không activate | `uv run <command>` |
|
||||
|
||||
> ℹ️ Vẫn dùng `pip` được — xem mục **Cài đặt môi trường (pip)** ở dưới. Nhưng `uv` nhanh hơn 10–100× và đảm bảo cùng phiên bản deps trên mọi máy nhờ `uv.lock`.
|
||||
|
||||
---
|
||||
|
||||
## Mục tiêu project
|
||||
|
||||
Crawl tài liệu từ:
|
||||
|
||||
* [https://api.intra-mart.jp/iap/](https://api.intra-mart.jp/iap/)
|
||||
* [https://document.intra-mart.jp/library/](https://document.intra-mart.jp/library/)
|
||||
|
||||
rồi convert toàn bộ sang Markdown có:
|
||||
|
||||
* giữ nguyên hierarchy theo URL path
|
||||
* giữ internal links (rewrite sang đường dẫn local)
|
||||
* tải assets (ảnh, css, file đính kèm) về local
|
||||
* hỗ trợ JS-rendered docs (Playwright + Chromium)
|
||||
* resume được khi chạy lại (state nằm trong SQLite)
|
||||
* output sạch để dùng với:
|
||||
|
||||
* Obsidian
|
||||
* MkDocs
|
||||
* Docusaurus
|
||||
* RAG / LLM
|
||||
* GitBook
|
||||
|
||||
---
|
||||
|
||||
## Kiến trúc tổng thể
|
||||
|
||||
Toàn bộ pipeline nằm trong `main.py` (`DocumentationCrawler`), điều phối các module con. Flow mỗi page:
|
||||
|
||||
```text
|
||||
URLManager.normalize / is_allowed
|
||||
↓
|
||||
PlaywrightClient.fetch_page (Chromium, JS-rendered, retry qua tenacity)
|
||||
↓
|
||||
HTMLCleaner.clean (loại REMOVE_SELECTORS)
|
||||
↓
|
||||
HTMLExtractor.extract_main_content (match CONTENT_SELECTORS đầu tiên)
|
||||
↓
|
||||
AssetDownloader (download + rewrite asset link sang local)
|
||||
↓
|
||||
MarkdownConverter.html_to_markdown (Pandoc → GFM, kèm YAML frontmatter)
|
||||
↓
|
||||
FileWriter / PathMapper (output/markdown/<domain>/<sanitized path>.md)
|
||||
↓
|
||||
extract_links + SphinxDiscovery + NavigationParser → enqueue URL mới
|
||||
```
|
||||
|
||||
### State nằm ở SQLite, không phải in-memory
|
||||
|
||||
`storage/metadata_db.py` (file `data/metadata.db`) là source of truth.
|
||||
|
||||
Bảng chính:
|
||||
|
||||
* `crawl_queue` — drive vòng lặp crawl: `pending` / `processing` / `retry` / `failed` / `done`. Lấy URL kế tiếp theo `priority DESC, depth ASC, discovered_at ASC`.
|
||||
* `pages` — metadata page crawl thành công + đường dẫn markdown output.
|
||||
* `assets`, `page_links`, `discovery_sources`, `failures` — provenance + site graph.
|
||||
|
||||
Chạy lại `python main.py` sẽ resume: URL `done` bị bỏ qua, URL đang `processing` bị reset về `retry` khi khởi động.
|
||||
|
||||
> ⚠️ Crawl loop hiện chạy **sequential single-worker** (chưa có asyncio worker pool). Để giới hạn memory, Playwright được restart mỗi `BROWSER_RECYCLE_EVERY = 500` page.
|
||||
|
||||
### Discovery đa nguồn
|
||||
|
||||
URL được phát hiện từ 3 nguồn, merge lại trước khi enqueue:
|
||||
|
||||
1. `DocumentationCrawler.extract_links` — generic `<a href>` + selector đặc thù Sphinx (`a.reference.internal`, `.toctree-wrapper a`, `link[rel=next/prev/up]`).
|
||||
2. `SphinxDiscovery` — probe `searchindex.js`, `genindex.html`, …
|
||||
3. `NavigationParser` — dựng nav tree để export `SUMMARY.md` / `navigation_tree.json`.
|
||||
|
||||
### URL → filesystem mapping
|
||||
|
||||
`storage/path_mapper.py` map sang `<output_dir>/<domain>/<sanitized path>.md`:
|
||||
|
||||
* sanitize ký tự Windows-invalid + reserved names
|
||||
* hash query string thành suffix `__<hash>`
|
||||
* rút gọn tên > 180 ký tự bằng MD5 tail
|
||||
|
||||
Asset link rewrite trong `extractor/asset_downloader.py` tính path **tương đối với output path của page hiện tại** — nếu đổi layout output của `PathMapper`, phải sửa luôn `rewrite_asset_links`.
|
||||
|
||||
---
|
||||
|
||||
## Công nghệ sử dụng
|
||||
|
||||
| Thành phần | Công nghệ |
|
||||
| ------------------- | ------------------ |
|
||||
| Rendering | Playwright (Chromium) |
|
||||
| HTTP fast-path | httpx |
|
||||
| HTML Parse | BeautifulSoup4 + lxml |
|
||||
| URL handling | urllib.parse |
|
||||
| Markdown conversion | Pandoc (qua pypandoc / markdownify) |
|
||||
| State & queue | SQLite |
|
||||
| Logging | loguru |
|
||||
| Retry | tenacity |
|
||||
| Config | constants ở đầu `main.py` (xem mục Configuration) |
|
||||
|
||||
---
|
||||
|
||||
## Cấu trúc project
|
||||
|
||||
```text
|
||||
intra-mart-crawler/
|
||||
│
|
||||
├── README.md
|
||||
├── CLAUDE.md
|
||||
├── requirements.txt
|
||||
├── pyproject.toml
|
||||
├── config.yaml ← KHÔNG được main.py đọc (xem Configuration)
|
||||
├── main.py ← entrypoint + DocumentationCrawler
|
||||
│
|
||||
├── crawler/
|
||||
│ ├── crawler.py
|
||||
│ ├── http_client.py
|
||||
│ ├── playwright_client.py
|
||||
│ ├── url_manager.py
|
||||
│ ├── sitemap_parser.py
|
||||
│ ├── sphinx_discovery.py
|
||||
│ └── navigation_parser.py
|
||||
│
|
||||
├── extractor/
|
||||
│ ├── html_extractor.py
|
||||
│ ├── html_cleaner.py
|
||||
│ └── asset_downloader.py
|
||||
│
|
||||
├── converter/
|
||||
│ ├── markdown_converter.py
|
||||
│ └── link_rewriter.py
|
||||
│
|
||||
├── storage/
|
||||
│ ├── file_writer.py
|
||||
│ ├── metadata_db.py ← SQLite source of truth
|
||||
│ └── path_mapper.py
|
||||
│
|
||||
├── models/
|
||||
│ ├── page.py
|
||||
│ └── crawl_result.py
|
||||
│
|
||||
├── utils/
|
||||
│ ├── logger.py
|
||||
│ ├── retry.py
|
||||
│ └── helpers.py
|
||||
│
|
||||
├── output/ ← runtime artifact (gitignored)
|
||||
│ ├── markdown/
|
||||
│ └── assets/
|
||||
│
|
||||
├── data/ ← runtime artifact (gitignored)
|
||||
│ └── metadata.db
|
||||
│
|
||||
└── logs/ ← rotating logs (gitignored)
|
||||
├── crawler.log
|
||||
├── errors.log
|
||||
└── debug.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cài đặt môi trường (pip — cách cũ)
|
||||
|
||||
> Nếu bạn dùng `uv` thì có thể bỏ qua section này — xem **Quickstart với `uv`** ở đầu file.
|
||||
|
||||
Yêu cầu: **Python >= 3.11** và **Pandoc** có trên PATH.
|
||||
|
||||
```bash
|
||||
# 1. Python deps
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Browser cho Playwright
|
||||
playwright install chromium
|
||||
|
||||
# 3. Pandoc
|
||||
sudo apt install pandoc # Ubuntu/Debian
|
||||
# macOS: brew install pandoc
|
||||
# Windows: winget install --id JohnMacFarlane.Pandoc
|
||||
# hoặc https://pandoc.org/installing.html
|
||||
```
|
||||
|
||||
### Dev environment (lint / format / type-check / test)
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
|
||||
ruff check .
|
||||
black .
|
||||
mypy .
|
||||
pytest # asyncio_mode = auto đã preset
|
||||
pytest tests/test_x.py::test_name # chạy 1 test
|
||||
```
|
||||
|
||||
Line length pinned ở **79** cho cả `black` và `ruff`. Target Python 3.11.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
> ⚠️ `config.yaml` **không được `main.py` đọc**. Đây là tàn dư từ thiết kế cũ — sẽ refactor sau.
|
||||
|
||||
Cấu hình "live" là các hằng số ở đầu `main.py`:
|
||||
|
||||
| Constant | Ý nghĩa |
|
||||
| ----------------------- | ------------------------------------------------------- |
|
||||
| `START_URLS` | Seed URLs để crawl |
|
||||
| `ALLOWED_DOMAINS` | Whitelist domain — ngoài list này sẽ không enqueue |
|
||||
| `OUTPUT_DIR` | Thư mục output gốc (mặc định `output/`) |
|
||||
| `REMOVE_SELECTORS` | CSS selectors bị drop trước khi extract |
|
||||
| `CONTENT_SELECTORS` | Selectors thử lần lượt để lấy main content |
|
||||
| `MAX_PAGES_PER_RUN` | Số page tối đa mỗi lần chạy |
|
||||
| `MAX_ATTEMPTS` | Số lần retry trước khi mark `failed` |
|
||||
| `BROWSER_RECYCLE_EVERY` | Restart Chromium sau mỗi N page (giới hạn memory leak) |
|
||||
|
||||
Muốn đổi behaviour → sửa các constant này, **không phải `config.yaml`**.
|
||||
|
||||
---
|
||||
|
||||
## Chạy project
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Chạy lại lệnh trên = resume từ SQLite queue. URL đã `done` bị skip, URL `processing` reset về `retry`.
|
||||
|
||||
---
|
||||
|
||||
## Output ví dụ
|
||||
|
||||
```text
|
||||
output/
|
||||
├── markdown/
|
||||
│ ├── api.intra-mart.jp/
|
||||
│ │ └── iap/
|
||||
│ │ ├── index.md
|
||||
│ │ ├── javadoc/
|
||||
│ │ │ └── ...
|
||||
│ │ └── apilist-ssjs/
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ └── document.intra-mart.jp/
|
||||
│ └── library/
|
||||
│ ├── getting-started.md
|
||||
│ └── workflow.md
|
||||
│
|
||||
└── assets/
|
||||
└── ... (ảnh, css, file đính kèm)
|
||||
```
|
||||
|
||||
Mỗi file `.md` có YAML frontmatter (source URL, crawl time, ...) và đã rewrite internal link + asset link sang relative path.
|
||||
|
||||
Ngoài ra:
|
||||
|
||||
* `SUMMARY.md` — index theo nav tree (cho GitBook / mdBook).
|
||||
* `navigation_tree.json` — nav tree dạng JSON (cho MkDocs / Docusaurus / custom sidebar).
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
`utils/logger.py` cấu hình loguru ngay khi import (chỉ cần `from utils.logger import logger`). Ghi rotating log vào `logs/crawler.log`, `logs/errors.log`, `logs/debug.log` — không cần setup gì thêm.
|
||||
|
||||
---
|
||||
|
||||
## Hướng phát triển tiếp theo
|
||||
|
||||
### Trong scope crawler
|
||||
|
||||
1. **Async concurrent crawling** — hiện crawl sequential, có thể thêm asyncio worker pool + semaphore + rate limiting.
|
||||
2. **Better extraction fallback** — bổ sung `trafilatura` / `readability-lxml` cho page không match `CONTENT_SELECTORS` nào.
|
||||
3. **Duplicate detection** — hash HTML/markdown content để skip page trùng nội dung khác URL.
|
||||
4. **Đọc `config.yaml` thực sự** (hoặc bỏ hẳn file này) — đồng bộ với constants trong `main.py`.
|
||||
5. **Incremental update** — re-crawl chỉ những page có `ETag` / `Last-Modified` thay đổi.
|
||||
|
||||
### Hệ sinh thái sau crawler
|
||||
|
||||
1. Build MkDocs / Docusaurus site từ output.
|
||||
2. Build vector database + embedding docs.
|
||||
3. Tạo RAG chatbot trên docs đã embed.
|
||||
4. Semantic search UI.
|
||||
5. Git sync pipeline cho output markdown.
|
||||
|
|
|
|||
34
config.yaml
Normal file
34
config.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
seed_urls:
|
||||
- "https://api.intra-mart.jp/iap/index.html"
|
||||
- "https://document.intra-mart.jp/library/"
|
||||
|
||||
allowed_domains:
|
||||
- "api.intra-mart.jp"
|
||||
- "document.intra-mart.jp"
|
||||
|
||||
output_dir: "output"
|
||||
|
||||
crawl:
|
||||
concurrency: 5
|
||||
delay_seconds: 1
|
||||
timeout: 30000
|
||||
max_retries: 3
|
||||
|
||||
playwright:
|
||||
headless: true
|
||||
|
||||
selectors:
|
||||
main_content:
|
||||
- "main"
|
||||
- "article"
|
||||
- ".content"
|
||||
- ".document"
|
||||
|
||||
remove:
|
||||
- "nav"
|
||||
- "footer"
|
||||
- "header"
|
||||
- ".sidebar"
|
||||
- ".toc"
|
||||
- "script"
|
||||
- "style"
|
||||
0
converter/__init__.py
Normal file
0
converter/__init__.py
Normal file
201
converter/link_rewriter.py
Normal file
201
converter/link_rewriter.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
class LinkRewriter:
|
||||
"""
|
||||
Rewrite internal intra-mart links
|
||||
from online URLs -> local markdown paths
|
||||
|
||||
Example:
|
||||
|
||||
https://api.intra-mart.jp/iap/auth/login.html
|
||||
|
||||
=>
|
||||
../auth/login.md
|
||||
"""
|
||||
|
||||
def __init__(self, output_dir: str):
|
||||
self.output_dir = Path(output_dir)
|
||||
|
||||
def rewrite(
|
||||
self,
|
||||
markdown: str,
|
||||
current_url: str,
|
||||
current_output_path: Path
|
||||
):
|
||||
"""
|
||||
Rewrite markdown links
|
||||
|
||||
Parameters
|
||||
----------
|
||||
markdown : str
|
||||
markdown content
|
||||
|
||||
current_url : str
|
||||
original page url
|
||||
|
||||
current_output_path : Path
|
||||
local markdown file path
|
||||
"""
|
||||
|
||||
lines = markdown.splitlines()
|
||||
|
||||
rewritten_lines = []
|
||||
|
||||
for line in lines:
|
||||
|
||||
rewritten = self.rewrite_markdown_links(
|
||||
line,
|
||||
current_output_path
|
||||
)
|
||||
|
||||
rewritten_lines.append(rewritten)
|
||||
|
||||
return "\n".join(rewritten_lines)
|
||||
|
||||
def rewrite_markdown_links(
|
||||
self,
|
||||
line: str,
|
||||
current_output_path: Path
|
||||
):
|
||||
"""
|
||||
Rewrite markdown inline links
|
||||
|
||||
Example:
|
||||
[API](https://api.intra-mart.jp/iap/auth.html)
|
||||
|
||||
->
|
||||
[API](../auth.md)
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
pattern = r"\[([^\]]+)\]\(([^)]+)\)"
|
||||
|
||||
matches = re.findall(pattern, line)
|
||||
|
||||
if not matches:
|
||||
return line
|
||||
|
||||
rewritten_line = line
|
||||
|
||||
for text, url in matches:
|
||||
|
||||
if not self.is_internal_url(url):
|
||||
continue
|
||||
|
||||
local_path = self.url_to_markdown_path(url)
|
||||
|
||||
relative_path = self.make_relative_path(
|
||||
current_output_path,
|
||||
local_path
|
||||
)
|
||||
|
||||
old = f"[{text}]({url})"
|
||||
|
||||
new = f"[{text}]({relative_path})"
|
||||
|
||||
rewritten_line = rewritten_line.replace(
|
||||
old,
|
||||
new
|
||||
)
|
||||
|
||||
return rewritten_line
|
||||
|
||||
def is_internal_url(self, url: str):
|
||||
"""
|
||||
Check if URL belongs to intra-mart docs
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
allowed_domains = [
|
||||
"api.intra-mart.jp",
|
||||
"document.intra-mart.jp"
|
||||
]
|
||||
|
||||
return any(
|
||||
domain in parsed.netloc
|
||||
for domain in allowed_domains
|
||||
)
|
||||
|
||||
def url_to_markdown_path(self, url: str):
|
||||
"""
|
||||
Convert URL -> local markdown path
|
||||
|
||||
Example:
|
||||
https://api.intra-mart.jp/iap/auth/login.html
|
||||
|
||||
=>
|
||||
output/markdown/iap/auth/login.md
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
path = parsed.path.strip("/")
|
||||
|
||||
if not path:
|
||||
path = "index"
|
||||
|
||||
if path.endswith(".html"):
|
||||
path = path[:-5]
|
||||
|
||||
local_path = (
|
||||
self.output_dir /
|
||||
(path + ".md")
|
||||
)
|
||||
|
||||
return local_path
|
||||
|
||||
def make_relative_path(
|
||||
self,
|
||||
current_file: Path,
|
||||
target_file: Path
|
||||
):
|
||||
"""
|
||||
Generate relative markdown path
|
||||
|
||||
Example:
|
||||
|
||||
current:
|
||||
output/markdown/iap/index.md
|
||||
|
||||
target:
|
||||
output/markdown/iap/auth/login.md
|
||||
|
||||
=>
|
||||
auth/login.md
|
||||
"""
|
||||
|
||||
current_dir = current_file.parent
|
||||
|
||||
relative = target_file.relative_to(
|
||||
self.output_dir
|
||||
)
|
||||
|
||||
target_absolute = self.output_dir / relative
|
||||
|
||||
relative_path = Path(
|
||||
target_absolute.relative_to(current_dir)
|
||||
)
|
||||
|
||||
try:
|
||||
relative_path = target_absolute.relative_to(
|
||||
current_dir
|
||||
)
|
||||
|
||||
return str(relative_path).replace("\\", "/")
|
||||
|
||||
except Exception:
|
||||
|
||||
import os
|
||||
|
||||
relative_path = os.path.relpath(
|
||||
target_absolute,
|
||||
current_dir
|
||||
)
|
||||
|
||||
return relative_path.replace("\\", "/")
|
||||
460
converter/markdown_converter.py
Normal file
460
converter/markdown_converter.py
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
try:
|
||||
from markdownify import markdownify as _markdownify
|
||||
except ImportError: # pragma: no cover - optional dep gate
|
||||
_markdownify = None
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class MarkdownConverter:
|
||||
"""
|
||||
HTML -> Markdown converter.
|
||||
|
||||
Two engines are available:
|
||||
|
||||
- ``markdownify`` (default): pure Python, no subprocess, ~10x
|
||||
faster per page. Fidelity is good for documentation HTML.
|
||||
- ``pandoc``: spawns the pandoc binary; higher fidelity for
|
||||
pathological HTML but pays a process-start cost per page.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine="markdownify",
|
||||
pandoc_path="pandoc",
|
||||
timeout=120,
|
||||
media_dir="output/assets/pandoc"
|
||||
):
|
||||
self.engine = engine
|
||||
|
||||
self.pandoc_path = pandoc_path
|
||||
|
||||
self.timeout = timeout
|
||||
|
||||
self.media_dir = Path(media_dir)
|
||||
self.media_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.engine == "pandoc":
|
||||
self.validate_pandoc()
|
||||
elif self.engine == "markdownify":
|
||||
if _markdownify is None:
|
||||
raise RuntimeError(
|
||||
"markdownify is not installed. "
|
||||
"pip install markdownify, or pass "
|
||||
"engine='pandoc'."
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown markdown engine: {self.engine!r}"
|
||||
)
|
||||
|
||||
def validate_pandoc(self):
|
||||
"""
|
||||
Ensure pandoc exists.
|
||||
"""
|
||||
|
||||
if not shutil.which(
|
||||
self.pandoc_path
|
||||
):
|
||||
|
||||
raise RuntimeError(
|
||||
"Pandoc not found. "
|
||||
"Please install pandoc."
|
||||
)
|
||||
|
||||
def html_to_markdown(
|
||||
self,
|
||||
html: str,
|
||||
title=None,
|
||||
source_url=None,
|
||||
media_dir=None
|
||||
):
|
||||
"""
|
||||
Convert HTML -> Markdown using the configured engine.
|
||||
"""
|
||||
|
||||
html = self.preprocess_html(html)
|
||||
|
||||
if self.engine == "markdownify":
|
||||
markdown = self._convert_markdownify(html)
|
||||
else:
|
||||
markdown = self._convert_pandoc(html, media_dir)
|
||||
|
||||
markdown = self.postprocess_markdown(markdown)
|
||||
|
||||
markdown = self.add_frontmatter(
|
||||
markdown,
|
||||
title=title,
|
||||
source_url=source_url,
|
||||
)
|
||||
|
||||
logger.debug("Markdown conversion completed.")
|
||||
|
||||
return markdown
|
||||
|
||||
def _convert_markdownify(self, html: str) -> str:
|
||||
"""
|
||||
In-process HTML -> Markdown via the markdownify
|
||||
library. Roughly 10x faster per page than spawning
|
||||
pandoc.
|
||||
"""
|
||||
|
||||
def code_lang_cb(el):
|
||||
# HTMLCleaner.normalize_code_blocks already sets
|
||||
# data-language on <pre> when it can detect one.
|
||||
return el.get("data-language") or ""
|
||||
|
||||
try:
|
||||
return _markdownify(
|
||||
html,
|
||||
heading_style="ATX",
|
||||
code_language_callback=code_lang_cb,
|
||||
bullets="-",
|
||||
strip=["script", "style"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"markdownify failed: {e}")
|
||||
raise
|
||||
|
||||
def _convert_pandoc(self, html: str, media_dir) -> str:
|
||||
"""
|
||||
Fallback engine: shell out to pandoc.
|
||||
"""
|
||||
|
||||
temp_html_path = None
|
||||
temp_md_path = None
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".html",
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
) as temp_html:
|
||||
temp_html.write(html)
|
||||
temp_html_path = temp_html.name
|
||||
|
||||
temp_md_path = temp_html_path + ".md"
|
||||
|
||||
extract_media_dir = (
|
||||
Path(media_dir) if media_dir else self.media_dir
|
||||
)
|
||||
extract_media_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
command = [
|
||||
self.pandoc_path,
|
||||
temp_html_path,
|
||||
"-f", "html",
|
||||
"-t", "gfm",
|
||||
"--wrap=none",
|
||||
"--markdown-headings=atx",
|
||||
f"--extract-media={extract_media_dir}",
|
||||
"-o", temp_md_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Pandoc failed: {result.stderr}")
|
||||
raise RuntimeError(result.stderr)
|
||||
|
||||
with open(
|
||||
temp_md_path,
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
return f.read()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Pandoc conversion timeout.")
|
||||
raise
|
||||
|
||||
finally:
|
||||
self.cleanup_temp_file(temp_html_path)
|
||||
self.cleanup_temp_file(temp_md_path)
|
||||
|
||||
def preprocess_html(
|
||||
self,
|
||||
html: str
|
||||
):
|
||||
"""
|
||||
Clean HTML before conversion.
|
||||
"""
|
||||
|
||||
soup = BeautifulSoup(
|
||||
html,
|
||||
"lxml"
|
||||
)
|
||||
|
||||
# Remove scripts/styles
|
||||
for tag in soup.find_all(
|
||||
[
|
||||
"script",
|
||||
"style",
|
||||
"noscript"
|
||||
]
|
||||
):
|
||||
|
||||
tag.decompose()
|
||||
|
||||
# Normalize code blocks
|
||||
for pre in soup.find_all("pre"):
|
||||
|
||||
code = pre.find("code")
|
||||
|
||||
if not code:
|
||||
continue
|
||||
|
||||
classes = code.get(
|
||||
"class",
|
||||
[]
|
||||
)
|
||||
|
||||
for cls in classes:
|
||||
|
||||
if cls.startswith(
|
||||
"language-"
|
||||
):
|
||||
|
||||
lang = cls.replace(
|
||||
"language-",
|
||||
""
|
||||
)
|
||||
|
||||
pre["data-language"] = lang
|
||||
|
||||
return str(soup)
|
||||
|
||||
def postprocess_markdown(
|
||||
self,
|
||||
markdown: str
|
||||
):
|
||||
"""
|
||||
Normalize markdown output.
|
||||
"""
|
||||
|
||||
lines = markdown.splitlines()
|
||||
|
||||
cleaned = []
|
||||
|
||||
previous_empty = False
|
||||
|
||||
for line in lines:
|
||||
|
||||
stripped = line.rstrip()
|
||||
|
||||
# Collapse excessive empty lines
|
||||
if not stripped:
|
||||
|
||||
if previous_empty:
|
||||
continue
|
||||
|
||||
previous_empty = True
|
||||
|
||||
else:
|
||||
|
||||
previous_empty = False
|
||||
|
||||
cleaned.append(
|
||||
stripped
|
||||
)
|
||||
|
||||
markdown = "\n".join(cleaned)
|
||||
|
||||
markdown = self.fix_code_fences(
|
||||
markdown
|
||||
)
|
||||
|
||||
markdown = self.fix_tables(
|
||||
markdown
|
||||
)
|
||||
|
||||
return markdown.strip()
|
||||
|
||||
def fix_code_fences(
|
||||
self,
|
||||
markdown: str
|
||||
):
|
||||
"""
|
||||
Improve fenced code blocks.
|
||||
"""
|
||||
|
||||
lines = markdown.splitlines()
|
||||
|
||||
output = []
|
||||
|
||||
in_code = False
|
||||
|
||||
for line in lines:
|
||||
|
||||
if line.startswith("```"):
|
||||
|
||||
in_code = not in_code
|
||||
|
||||
output.append(line)
|
||||
|
||||
# Close unclosed code block
|
||||
if in_code:
|
||||
|
||||
output.append("```")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def fix_tables(
|
||||
self,
|
||||
markdown: str
|
||||
):
|
||||
"""
|
||||
Fix malformed markdown tables.
|
||||
"""
|
||||
|
||||
# Future enhancement hook
|
||||
|
||||
return markdown
|
||||
|
||||
def add_frontmatter(
|
||||
self,
|
||||
markdown,
|
||||
title=None,
|
||||
source_url=None
|
||||
):
|
||||
"""
|
||||
Add YAML frontmatter.
|
||||
|
||||
Values are escaped so that titles or URLs containing
|
||||
quotes / backslashes / newlines do not produce
|
||||
broken YAML.
|
||||
"""
|
||||
|
||||
metadata = []
|
||||
|
||||
if title:
|
||||
|
||||
metadata.append(
|
||||
f'title: {self._yaml_quote(title)}'
|
||||
)
|
||||
|
||||
if source_url:
|
||||
|
||||
metadata.append(
|
||||
f'source_url: {self._yaml_quote(source_url)}'
|
||||
)
|
||||
|
||||
if not metadata:
|
||||
|
||||
return markdown
|
||||
|
||||
frontmatter = "---\n"
|
||||
|
||||
frontmatter += "\n".join(
|
||||
metadata
|
||||
)
|
||||
|
||||
frontmatter += "\n---\n\n"
|
||||
|
||||
return frontmatter + markdown
|
||||
|
||||
def _yaml_quote(
|
||||
self,
|
||||
value
|
||||
):
|
||||
"""
|
||||
Safely double-quote a value for YAML frontmatter.
|
||||
|
||||
Escapes backslashes, double quotes and control chars
|
||||
(newline / carriage return / tab) which are otherwise
|
||||
illegal inside a double-quoted YAML scalar.
|
||||
"""
|
||||
|
||||
text = str(value)
|
||||
|
||||
text = (
|
||||
text
|
||||
.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
|
||||
return f'"{text}"'
|
||||
|
||||
def cleanup_temp_file(
|
||||
self,
|
||||
path
|
||||
):
|
||||
"""
|
||||
Safely remove temp file.
|
||||
"""
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
Path(path).unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Temp cleanup failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
def batch_convert(
|
||||
self,
|
||||
html_documents
|
||||
):
|
||||
"""
|
||||
Batch conversion helper.
|
||||
|
||||
html_documents:
|
||||
[
|
||||
{
|
||||
"html": "...",
|
||||
"title": "...",
|
||||
"url": "..."
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
results = []
|
||||
|
||||
for doc in html_documents:
|
||||
|
||||
try:
|
||||
|
||||
markdown = self.html_to_markdown(
|
||||
html=doc["html"],
|
||||
title=doc.get("title"),
|
||||
source_url=doc.get("url")
|
||||
)
|
||||
|
||||
results.append({
|
||||
"success": True,
|
||||
"markdown": markdown
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
results.append({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
0
crawler/__init__.py
Normal file
0
crawler/__init__.py
Normal file
248
crawler/crawler.py
Normal file
248
crawler/crawler.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import asyncio
|
||||
from typing import Set
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from crawler.playwright_client import PlaywrightClient
|
||||
from crawler.url_manager import URLManager
|
||||
|
||||
from extractor.html_extractor import HTMLExtractor
|
||||
from extractor.html_cleaner import HTMLCleaner
|
||||
|
||||
from converter.markdown_converter import MarkdownConverter
|
||||
from converter.path_mapper import PathMapper
|
||||
|
||||
from storage.file_writer import FileWriter
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
from converter.link_rewriter import LinkRewriter
|
||||
from extractor.asset_downloader import AssetDownloader
|
||||
|
||||
class DocumentationCrawler:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
self.client = PlaywrightClient(
|
||||
timeout=config["crawl"]["timeout"],
|
||||
headless=config["playwright"]["headless"]
|
||||
)
|
||||
|
||||
self.url_manager = URLManager(
|
||||
config["allowed_domains"]
|
||||
)
|
||||
|
||||
self.extractor = HTMLExtractor(
|
||||
config["selectors"]["main_content"]
|
||||
)
|
||||
|
||||
self.cleaner = HTMLCleaner(
|
||||
config["selectors"]["remove"]
|
||||
)
|
||||
|
||||
self.converter = MarkdownConverter()
|
||||
|
||||
self.mapper = PathMapper(
|
||||
config["output_dir"] + "/markdown"
|
||||
)
|
||||
|
||||
self.writer = FileWriter()
|
||||
|
||||
self.link_rewriter = LinkRewriter(
|
||||
config["output_dir"] + "/markdown"
|
||||
)
|
||||
|
||||
self.asset_downloader = AssetDownloader(
|
||||
output_dir=config["output_dir"] + "/assets"
|
||||
)
|
||||
self.delay_seconds = config["crawl"]["delay_seconds"]
|
||||
self.max_retries = config["crawl"]["max_retries"]
|
||||
|
||||
async def crawl(self):
|
||||
"""
|
||||
Main crawling entry point
|
||||
"""
|
||||
|
||||
logger.info("Starting crawler...")
|
||||
|
||||
await self.client.start()
|
||||
|
||||
queue = asyncio.Queue()
|
||||
|
||||
for url in self.config["seed_urls"]:
|
||||
await queue.put(url)
|
||||
|
||||
try:
|
||||
while not queue.empty():
|
||||
|
||||
current_url = await queue.get()
|
||||
|
||||
current_url = self.url_manager.normalize(current_url)
|
||||
|
||||
if not self.url_manager.should_visit(current_url):
|
||||
continue
|
||||
|
||||
self.url_manager.mark_visited(current_url)
|
||||
|
||||
logger.info(f"Crawling: {current_url}")
|
||||
|
||||
try:
|
||||
await self.process_page(current_url, queue)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed processing {current_url}: {e}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(self.delay_seconds)
|
||||
|
||||
finally:
|
||||
await self.client.stop()
|
||||
|
||||
logger.info("Crawler stopped.")
|
||||
|
||||
async def process_page(
|
||||
self,
|
||||
url: str,
|
||||
queue: asyncio.Queue
|
||||
):
|
||||
"""
|
||||
Process single page:
|
||||
- render html
|
||||
- extract content
|
||||
- clean html
|
||||
- convert markdown
|
||||
- save markdown
|
||||
- discover links
|
||||
"""
|
||||
|
||||
page_data = await self.fetch_with_retry(url)
|
||||
|
||||
if not page_data:
|
||||
return
|
||||
|
||||
html = page_data["html"]
|
||||
|
||||
await self.asset_downloader.download_assets_from_html(
|
||||
base_url=url,
|
||||
html=html
|
||||
)
|
||||
|
||||
logger.info(f"Extracting content: {url}")
|
||||
|
||||
main_html = self.extractor.extract_main_content(html)
|
||||
|
||||
clean_html = self.cleaner.clean(main_html)
|
||||
|
||||
logger.info(f"Converting markdown: {url}")
|
||||
|
||||
markdown = self.converter.html_to_markdown(
|
||||
clean_html
|
||||
)
|
||||
|
||||
output_path = self.mapper.url_to_path(url)
|
||||
|
||||
markdown = self.link_rewriter.rewrite(
|
||||
markdown=markdown,
|
||||
current_url=url,
|
||||
current_output_path=output_path
|
||||
)
|
||||
|
||||
self.writer.write_text(
|
||||
output_path,
|
||||
markdown
|
||||
)
|
||||
|
||||
logger.info(f"Saved markdown: {output_path}")
|
||||
|
||||
await self.discover_links(
|
||||
base_url=url,
|
||||
html=html,
|
||||
queue=queue
|
||||
)
|
||||
|
||||
async def fetch_with_retry(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Retry wrapper for page fetching
|
||||
"""
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Fetching ({attempt}/{self.max_retries}): {url}"
|
||||
)
|
||||
|
||||
return await self.client.fetch_page(url)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Retry {attempt} failed for {url}: {e}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
logger.error(f"Max retries exceeded: {url}")
|
||||
|
||||
return None
|
||||
|
||||
async def discover_links(
|
||||
self,
|
||||
base_url: str,
|
||||
html: str,
|
||||
queue: asyncio.Queue
|
||||
):
|
||||
"""
|
||||
Extract all links from page
|
||||
and push valid links into queue
|
||||
"""
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
links_found = 0
|
||||
|
||||
for a in soup.find_all("a", href=True):
|
||||
|
||||
href = a["href"].strip()
|
||||
|
||||
if not href:
|
||||
continue
|
||||
|
||||
if href.startswith("#"):
|
||||
continue
|
||||
|
||||
if href.startswith("mailto:"):
|
||||
continue
|
||||
|
||||
if href.startswith("javascript:"):
|
||||
continue
|
||||
|
||||
try:
|
||||
next_url = self.url_manager.resolve(
|
||||
base_url,
|
||||
href
|
||||
)
|
||||
|
||||
next_url = self.url_manager.normalize(
|
||||
next_url
|
||||
)
|
||||
|
||||
if self.url_manager.should_visit(next_url):
|
||||
|
||||
await queue.put(next_url)
|
||||
|
||||
links_found += 1
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Invalid URL {href} from {base_url}: {e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Discovered {links_found} new links from {base_url}"
|
||||
)
|
||||
157
crawler/http_client.py
Normal file
157
crawler/http_client.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import httpx
|
||||
|
||||
from utils.logger import logger
|
||||
from crawler.playwright_client import is_binary_file
|
||||
|
||||
|
||||
def _http2_available() -> bool:
|
||||
"""
|
||||
httpx requires the optional 'h2' package for HTTP/2. Detect
|
||||
its presence so we transparently fall back to HTTP/1.1 +
|
||||
keep-alive when h2 isn't installed (no hard dependency).
|
||||
"""
|
||||
try:
|
||||
import h2 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
class HTTPClient:
|
||||
"""
|
||||
Lightweight HTTP fetcher used as the fast-path for
|
||||
static documentation pages. Returns a dict shaped like
|
||||
PlaywrightClient.fetch_page so process_page can consume
|
||||
either source.
|
||||
"""
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout=20,
|
||||
max_keepalive=20,
|
||||
max_connections=40,
|
||||
):
|
||||
self.timeout = timeout
|
||||
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
http2=_http2_available(),
|
||||
headers={"User-Agent": self.USER_AGENT},
|
||||
limits=httpx.Limits(
|
||||
max_connections=max_connections,
|
||||
max_keepalive_connections=max_keepalive,
|
||||
),
|
||||
)
|
||||
|
||||
async def aclose(self):
|
||||
await self.client.aclose()
|
||||
|
||||
async def fetch_page(
|
||||
self,
|
||||
url: str,
|
||||
etag: str | None = None,
|
||||
last_modified: str | None = None,
|
||||
):
|
||||
if is_binary_file(url):
|
||||
return {
|
||||
"url": url,
|
||||
"title": url.split("/")[-1],
|
||||
"html": "",
|
||||
"status_code": None,
|
||||
"headers": {},
|
||||
"is_binary": True,
|
||||
"content_type": "",
|
||||
}
|
||||
|
||||
# Build conditional-GET headers when we have a prior
|
||||
# ETag / Last-Modified for this URL. A 304 response
|
||||
# lets the caller skip re-fetching, re-parsing, and
|
||||
# re-writing markdown — pure savings on resumed runs.
|
||||
headers = {}
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
if last_modified:
|
||||
headers["If-Modified-Since"] = last_modified
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, headers=headers)
|
||||
except Exception as e:
|
||||
logger.warning(f"HTTP fetch failed {url}: {e}")
|
||||
return None
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
|
||||
if response.status_code == 304:
|
||||
return {
|
||||
"url": str(response.url),
|
||||
"title": "",
|
||||
"html": "",
|
||||
"status_code": 304,
|
||||
"headers": dict(response.headers),
|
||||
"is_binary": False,
|
||||
"content_type": content_type,
|
||||
"not_modified": True,
|
||||
}
|
||||
|
||||
if response.status_code >= 400:
|
||||
return {
|
||||
"url": str(response.url),
|
||||
"title": "",
|
||||
"html": "",
|
||||
"status_code": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
"is_binary": False,
|
||||
"content_type": content_type,
|
||||
"ok": False,
|
||||
}
|
||||
|
||||
if "html" not in content_type.lower():
|
||||
# Treat non-HTML responses (e.g. application/pdf,
|
||||
# application/javascript) as binary so the caller
|
||||
# skips parsing them as documentation pages.
|
||||
return {
|
||||
"url": str(response.url),
|
||||
"title": url.split("/")[-1],
|
||||
"html": "",
|
||||
"status_code": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
"is_binary": True,
|
||||
"content_type": content_type,
|
||||
}
|
||||
|
||||
html = response.text
|
||||
title = self._extract_title(html)
|
||||
|
||||
return {
|
||||
"url": str(response.url),
|
||||
"title": title,
|
||||
"html": html,
|
||||
"status_code": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
"is_binary": False,
|
||||
"content_type": content_type,
|
||||
"ok": True,
|
||||
"etag": response.headers.get("etag"),
|
||||
"last_modified": response.headers.get("last-modified"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_title(html: str) -> str:
|
||||
# Avoid full BS4 parse for just a title — the next
|
||||
# stage will parse the HTML anyway.
|
||||
lower = html.lower()
|
||||
start = lower.find("<title")
|
||||
if start < 0:
|
||||
return ""
|
||||
gt = lower.find(">", start)
|
||||
end = lower.find("</title", gt)
|
||||
if gt < 0 or end < 0:
|
||||
return ""
|
||||
return html[gt + 1 : end].strip()
|
||||
557
crawler/navigation_parser.py
Normal file
557
crawler/navigation_parser.py
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
import json
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
def _ensure_soup(html_or_soup):
|
||||
if hasattr(html_or_soup, "select_one") and hasattr(
|
||||
html_or_soup, "find_all"
|
||||
):
|
||||
return html_or_soup
|
||||
return BeautifulSoup(html_or_soup, "lxml")
|
||||
|
||||
|
||||
|
||||
class NavigationParser:
|
||||
"""
|
||||
Parse documentation navigation/sidebar
|
||||
to preserve hierarchy structure.
|
||||
|
||||
Supports:
|
||||
- sidebar menus
|
||||
- nested navigation trees
|
||||
- table of contents
|
||||
- navigation JSON
|
||||
"""
|
||||
|
||||
def __init__(self, url_manager=None):
|
||||
self.discovered_urls = set()
|
||||
self.url_manager = url_manager
|
||||
|
||||
def parse_navigation(
|
||||
self,
|
||||
base_url: str,
|
||||
html
|
||||
):
|
||||
"""
|
||||
Main entry point. Accepts an HTML string or a parsed
|
||||
BeautifulSoup/Tag.
|
||||
|
||||
Returns:
|
||||
-------
|
||||
{
|
||||
"urls": [...],
|
||||
"tree": [...]
|
||||
}
|
||||
"""
|
||||
|
||||
soup = _ensure_soup(html)
|
||||
|
||||
urls = set()
|
||||
|
||||
tree = []
|
||||
|
||||
# Parse sidebar menus
|
||||
sidebar_tree = self.parse_sidebar_navigation(
|
||||
soup,
|
||||
base_url
|
||||
)
|
||||
|
||||
tree.extend(sidebar_tree)
|
||||
|
||||
urls.update(
|
||||
self.extract_urls_from_tree(sidebar_tree)
|
||||
)
|
||||
|
||||
sphinx_tree = self.parse_sphinx_toctree(
|
||||
soup,
|
||||
base_url
|
||||
)
|
||||
|
||||
tree.extend(sphinx_tree)
|
||||
|
||||
urls.update(
|
||||
self.extract_urls_from_tree(sphinx_tree)
|
||||
)
|
||||
|
||||
# Parse TOC
|
||||
toc_tree = self.parse_toc_navigation(
|
||||
soup,
|
||||
base_url
|
||||
)
|
||||
|
||||
tree.extend(toc_tree)
|
||||
|
||||
urls.update(
|
||||
self.extract_urls_from_tree(toc_tree)
|
||||
)
|
||||
|
||||
|
||||
breadcrumb_tree = self.parse_breadcrumbs(
|
||||
soup,
|
||||
base_url
|
||||
)
|
||||
|
||||
tree.extend(breadcrumb_tree)
|
||||
|
||||
urls.update(
|
||||
self.extract_urls_from_tree(breadcrumb_tree)
|
||||
)
|
||||
|
||||
# Parse generic nav blocks
|
||||
nav_tree = self.parse_generic_navigation(
|
||||
soup,
|
||||
base_url
|
||||
)
|
||||
|
||||
tree.extend(nav_tree)
|
||||
|
||||
urls.update(
|
||||
self.extract_urls_from_tree(nav_tree)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Navigation parser found {len(urls)} URLs"
|
||||
)
|
||||
|
||||
tree = self.dedupe_tree(tree)
|
||||
|
||||
return {
|
||||
"urls": list(urls),
|
||||
"tree": tree
|
||||
}
|
||||
|
||||
def parse_sidebar_navigation(
|
||||
self,
|
||||
soup,
|
||||
base_url
|
||||
):
|
||||
"""
|
||||
Parse sidebar-based docs navigation
|
||||
"""
|
||||
|
||||
selectors = [
|
||||
"nav",
|
||||
".sidebar",
|
||||
".navigation",
|
||||
".menu",
|
||||
".toc",
|
||||
".md-nav",
|
||||
".wy-menu",
|
||||
".theme-doc-sidebar-container",
|
||||
".theme-doc-sidebar-menu",
|
||||
".sphinxsidebar",
|
||||
".sphinxsidebarwrapper",
|
||||
".toctree-wrapper",
|
||||
".globaltoc",
|
||||
".localtoc",
|
||||
".relations",
|
||||
".related",
|
||||
".body .toctree-wrapper",
|
||||
".document .toctree-wrapper",
|
||||
".contents.topic"
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for selector in selectors:
|
||||
|
||||
nodes = soup.select(selector)
|
||||
|
||||
for node in nodes:
|
||||
|
||||
tree = self.parse_list_structure(
|
||||
node,
|
||||
base_url
|
||||
)
|
||||
|
||||
if tree:
|
||||
results.extend(tree)
|
||||
|
||||
return results
|
||||
|
||||
def parse_toc_navigation(
|
||||
self,
|
||||
soup,
|
||||
base_url
|
||||
):
|
||||
"""
|
||||
Parse table-of-contents sections
|
||||
"""
|
||||
|
||||
selectors = [
|
||||
".toc",
|
||||
".table-of-contents",
|
||||
"#table-of-contents",
|
||||
".contents",
|
||||
".markdown-toc"
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for selector in selectors:
|
||||
|
||||
nodes = soup.select(selector)
|
||||
|
||||
for node in nodes:
|
||||
|
||||
tree = self.parse_list_structure(
|
||||
node,
|
||||
base_url
|
||||
)
|
||||
|
||||
if tree:
|
||||
results.extend(tree)
|
||||
|
||||
return results
|
||||
|
||||
def parse_generic_navigation(
|
||||
self,
|
||||
soup,
|
||||
base_url
|
||||
):
|
||||
"""
|
||||
Fallback parser:
|
||||
extract all nav-like links
|
||||
"""
|
||||
|
||||
results = []
|
||||
|
||||
for a in soup.find_all("a", href=True):
|
||||
|
||||
href = a["href"].strip()
|
||||
|
||||
title = a.get_text(" ", strip=True)
|
||||
|
||||
if not title:
|
||||
continue
|
||||
|
||||
if not href:
|
||||
continue
|
||||
|
||||
if href.startswith("#"):
|
||||
continue
|
||||
|
||||
if href.startswith("javascript:"):
|
||||
continue
|
||||
|
||||
if href.startswith("mailto:"):
|
||||
continue
|
||||
|
||||
full_url = self.resolve_url(base_url, href)
|
||||
if not full_url:
|
||||
continue
|
||||
|
||||
item = {
|
||||
"title": title,
|
||||
"url": full_url,
|
||||
"children": []
|
||||
}
|
||||
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
|
||||
def parse_list_structure(
|
||||
self,
|
||||
node,
|
||||
base_url
|
||||
):
|
||||
"""
|
||||
Parse nested UL/LI navigation tree
|
||||
"""
|
||||
|
||||
results = []
|
||||
|
||||
root_lists = node.find_all(
|
||||
["ul", "ol"],
|
||||
recursive=False
|
||||
)
|
||||
|
||||
if not root_lists:
|
||||
root_lists = [node]
|
||||
|
||||
for ul in root_lists:
|
||||
|
||||
for li in ul.find_all("li", recursive=False):
|
||||
|
||||
item = self.parse_list_item(
|
||||
li,
|
||||
base_url
|
||||
)
|
||||
|
||||
if item:
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
|
||||
def parse_list_item(
|
||||
self,
|
||||
li,
|
||||
base_url
|
||||
):
|
||||
"""
|
||||
Parse single navigation item
|
||||
"""
|
||||
|
||||
a = li.find("a", href=True)
|
||||
|
||||
if not a:
|
||||
return None
|
||||
|
||||
href = a["href"].strip()
|
||||
|
||||
if not href:
|
||||
return None
|
||||
|
||||
full_url = self.resolve_url(base_url, href)
|
||||
|
||||
if not full_url:
|
||||
return None
|
||||
|
||||
title = a.get_text(" ", strip=True)
|
||||
|
||||
if not title:
|
||||
title = full_url.rsplit("/", 1)[-1]
|
||||
|
||||
item = {
|
||||
"title": title,
|
||||
"url": full_url,
|
||||
"children": []
|
||||
}
|
||||
|
||||
nested_lists = li.find_all(
|
||||
["ul", "ol"],
|
||||
recursive=False
|
||||
)
|
||||
|
||||
for nested in nested_lists:
|
||||
|
||||
for child_li in nested.find_all(
|
||||
"li",
|
||||
recursive=False
|
||||
):
|
||||
|
||||
child_item = self.parse_list_item(
|
||||
child_li,
|
||||
base_url
|
||||
)
|
||||
|
||||
if child_item:
|
||||
item["children"].append(
|
||||
child_item
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
def extract_urls_from_tree(
|
||||
self,
|
||||
tree
|
||||
):
|
||||
"""
|
||||
Flatten navigation tree into URL list
|
||||
"""
|
||||
|
||||
urls = set()
|
||||
|
||||
for item in tree:
|
||||
|
||||
url = item.get("url")
|
||||
|
||||
if url:
|
||||
urls.add(url)
|
||||
|
||||
children = item.get("children", [])
|
||||
|
||||
child_urls = self.extract_urls_from_tree(
|
||||
children
|
||||
)
|
||||
|
||||
urls.update(child_urls)
|
||||
|
||||
return urls
|
||||
|
||||
def export_tree_json(
|
||||
self,
|
||||
tree,
|
||||
output_path
|
||||
):
|
||||
"""
|
||||
Export navigation hierarchy to JSON
|
||||
"""
|
||||
|
||||
with open(
|
||||
output_path,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as f:
|
||||
|
||||
json.dump(
|
||||
tree,
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Navigation tree saved: {output_path}"
|
||||
)
|
||||
|
||||
def generate_summary_markdown(
|
||||
self,
|
||||
tree
|
||||
):
|
||||
"""
|
||||
Generate SUMMARY.md
|
||||
for mdBook / docs navigation
|
||||
"""
|
||||
|
||||
lines = ["# Summary", ""]
|
||||
|
||||
self.build_summary_lines(
|
||||
tree,
|
||||
lines,
|
||||
level=0
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def build_summary_lines(
|
||||
self,
|
||||
tree,
|
||||
lines,
|
||||
level=0
|
||||
):
|
||||
"""
|
||||
Recursive markdown tree builder
|
||||
"""
|
||||
|
||||
indent = " " * level
|
||||
|
||||
for item in tree:
|
||||
|
||||
title = item.get("title", "Untitled")
|
||||
|
||||
url = item.get("url", "#")
|
||||
|
||||
lines.append(
|
||||
f"{indent}- [{title}]({url})"
|
||||
)
|
||||
|
||||
children = item.get("children", [])
|
||||
|
||||
if children:
|
||||
|
||||
self.build_summary_lines(
|
||||
children,
|
||||
lines,
|
||||
level + 1
|
||||
)
|
||||
|
||||
def resolve_url(self, base_url, href):
|
||||
if not href:
|
||||
return None
|
||||
|
||||
href = href.strip()
|
||||
|
||||
if href.startswith("#"):
|
||||
return None
|
||||
|
||||
if href.startswith(("javascript:", "mailto:", "tel:")):
|
||||
return None
|
||||
|
||||
if self.url_manager:
|
||||
resolved = self.url_manager.resolve(base_url, href)
|
||||
|
||||
if not resolved:
|
||||
return None
|
||||
|
||||
if not self.url_manager.is_allowed(resolved):
|
||||
return None
|
||||
|
||||
return resolved
|
||||
|
||||
return urljoin(base_url, href)
|
||||
|
||||
def parse_sphinx_toctree(
|
||||
self,
|
||||
soup,
|
||||
base_url
|
||||
):
|
||||
selectors = [
|
||||
".toctree-wrapper",
|
||||
".sphinxsidebar",
|
||||
".sphinxsidebarwrapper",
|
||||
".globaltoc",
|
||||
".localtoc",
|
||||
".contents.topic"
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for selector in selectors:
|
||||
for node in soup.select(selector):
|
||||
tree = self.parse_list_structure(node, base_url)
|
||||
|
||||
if tree:
|
||||
results.extend(tree)
|
||||
|
||||
return results
|
||||
|
||||
def dedupe_tree(self, tree):
|
||||
seen = set()
|
||||
result = []
|
||||
|
||||
for item in tree:
|
||||
url = item.get("url")
|
||||
|
||||
if url and url in seen:
|
||||
continue
|
||||
|
||||
if url:
|
||||
seen.add(url)
|
||||
|
||||
children = item.get("children", [])
|
||||
|
||||
item["children"] = self.dedupe_tree(children)
|
||||
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
|
||||
def parse_breadcrumbs(
|
||||
self,
|
||||
soup,
|
||||
base_url
|
||||
):
|
||||
selectors = [
|
||||
".breadcrumb",
|
||||
".breadcrumbs",
|
||||
"nav[aria-label='breadcrumb']",
|
||||
".wy-breadcrumbs",
|
||||
".related"
|
||||
]
|
||||
|
||||
items = []
|
||||
|
||||
for selector in selectors:
|
||||
for node in soup.select(selector):
|
||||
for a in node.find_all("a", href=True):
|
||||
title = a.get_text(" ", strip=True)
|
||||
href = a.get("href")
|
||||
|
||||
url = self.resolve_url(base_url, href)
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
items.append({
|
||||
"title": title or url.rsplit("/", 1)[-1],
|
||||
"url": url,
|
||||
"children": []
|
||||
})
|
||||
|
||||
return items
|
||||
429
crawler/playwright_client.py
Normal file
429
crawler/playwright_client.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import asyncio
|
||||
|
||||
from playwright.async_api import (
|
||||
async_playwright,
|
||||
TimeoutError as PlaywrightTimeoutError,
|
||||
Error as PlaywrightError
|
||||
)
|
||||
|
||||
from utils.logger import logger
|
||||
from utils.retry import retry_async
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def is_binary_file(url: str):
|
||||
path = urlparse(url).path.lower()
|
||||
|
||||
binary_ext = (
|
||||
".xls", ".xlsx",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".doc",
|
||||
".docx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".xlsm",
|
||||
".csv"
|
||||
)
|
||||
|
||||
return any(path.endswith(ext) for ext in binary_ext)
|
||||
|
||||
class PlaywrightClient:
|
||||
"""
|
||||
Production-ready Playwright wrapper.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout=45000,
|
||||
headless=True,
|
||||
browser_type="chromium",
|
||||
content_selectors=None,
|
||||
selector_wait_ms=3000,
|
||||
blocked_resource_types=None,
|
||||
):
|
||||
self.timeout = timeout
|
||||
|
||||
self.headless = headless
|
||||
|
||||
self.browser_type = browser_type
|
||||
|
||||
# Selectors the caller considers "main content". If
|
||||
# provided, we wait for any of them to appear instead
|
||||
# of doing a blind fixed sleep — saves seconds per
|
||||
# page on docs that DOM-render quickly.
|
||||
self.content_selectors = content_selectors or []
|
||||
|
||||
self.selector_wait_ms = selector_wait_ms
|
||||
|
||||
# Resource types we abort at the network layer to
|
||||
# avoid downloading bytes Chromium will never use for
|
||||
# extraction. Images and stylesheets are also fetched
|
||||
# separately by AssetDownloader from the raw HTML, so
|
||||
# the browser doesn't need them.
|
||||
self.blocked_resource_types = set(
|
||||
blocked_resource_types
|
||||
if blocked_resource_types is not None
|
||||
else ("image", "stylesheet", "media", "font")
|
||||
)
|
||||
|
||||
self.playwright = None
|
||||
|
||||
self.browser = None
|
||||
|
||||
self.context = None
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Start Playwright browser.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Starting Playwright..."
|
||||
)
|
||||
|
||||
self.playwright = await async_playwright().start()
|
||||
|
||||
browser_launcher = getattr(
|
||||
self.playwright,
|
||||
self.browser_type
|
||||
)
|
||||
|
||||
self.browser = await browser_launcher.launch(
|
||||
headless=self.headless,
|
||||
|
||||
args=[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-dev-shm-usage",
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-gpu"
|
||||
]
|
||||
)
|
||||
|
||||
self.context = await self.browser.new_context(
|
||||
viewport={
|
||||
"width": 1600,
|
||||
"height": 900
|
||||
},
|
||||
|
||||
user_agent=(
|
||||
"Mozilla/5.0 "
|
||||
"(Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 "
|
||||
"Safari/537.36"
|
||||
),
|
||||
|
||||
locale="en-US",
|
||||
|
||||
java_script_enabled=True,
|
||||
|
||||
ignore_https_errors=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Playwright started."
|
||||
)
|
||||
|
||||
async def stop(self):
|
||||
"""
|
||||
Shutdown browser safely.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Stopping Playwright..."
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
if self.context:
|
||||
await self.context.close()
|
||||
|
||||
if self.browser:
|
||||
await self.browser.close()
|
||||
|
||||
if self.playwright:
|
||||
await self.playwright.stop()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Playwright stop error: {e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Playwright stopped."
|
||||
)
|
||||
|
||||
@retry_async(
|
||||
attempts=3,
|
||||
min_wait=2,
|
||||
max_wait=10
|
||||
)
|
||||
async def fetch_page(self, url: str):
|
||||
"""
|
||||
Render and fetch page safely (production version).
|
||||
"""
|
||||
|
||||
logger.info(f"Fetching page: {url}")
|
||||
|
||||
if is_binary_file(url):
|
||||
logger.info(f"Detected binary file, skipping as page: {url}")
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"title": url.split("/")[-1],
|
||||
"html": "",
|
||||
"status_code": None,
|
||||
"headers": {},
|
||||
"is_binary": True,
|
||||
"content_type": ""
|
||||
}
|
||||
|
||||
page = None
|
||||
|
||||
try:
|
||||
page = await self.context.new_page()
|
||||
|
||||
# Apply routing (ONLY HTML requests should be intercepted)
|
||||
await page.route(
|
||||
"**/*",
|
||||
self.route_interceptor
|
||||
)
|
||||
|
||||
|
||||
response = await page.goto(
|
||||
url,
|
||||
wait_until="domcontentloaded",
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
# Wait only as long as it takes for actual content
|
||||
# to appear, instead of the previous fixed 5s
|
||||
# load-state wait + 0.5s sleep. If the caller did
|
||||
# not configure content selectors, fall back to
|
||||
# the cheaper load-state wait.
|
||||
if self.content_selectors:
|
||||
joined = ",".join(self.content_selectors)
|
||||
try:
|
||||
await page.wait_for_selector(
|
||||
joined,
|
||||
timeout=self.selector_wait_ms,
|
||||
)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.debug(
|
||||
f"Content selector not seen within "
|
||||
f"{self.selector_wait_ms}ms, "
|
||||
f"continuing anyway: {url}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await page.wait_for_load_state(
|
||||
"load",
|
||||
timeout=self.selector_wait_ms,
|
||||
)
|
||||
except PlaywrightTimeoutError:
|
||||
logger.debug(
|
||||
f"Load state timeout, continuing "
|
||||
f"anyway: {url}"
|
||||
)
|
||||
|
||||
title = await page.title()
|
||||
html = await page.content()
|
||||
final_url = page.url
|
||||
|
||||
status = response.status if response else None
|
||||
headers = response.headers if response else {}
|
||||
|
||||
logger.info(f"Fetched page successfully: {url}")
|
||||
|
||||
return {
|
||||
"url": final_url,
|
||||
"title": title,
|
||||
"html": html,
|
||||
"status_code": status,
|
||||
"headers": headers,
|
||||
"is_binary": False
|
||||
}
|
||||
|
||||
except PlaywrightTimeoutError:
|
||||
|
||||
logger.error(f"Timeout while fetching: {url}")
|
||||
raise
|
||||
|
||||
except PlaywrightError as e:
|
||||
|
||||
if self.is_transient_error(e):
|
||||
logger.warning(
|
||||
f"Transient Playwright error for {url}: {e}"
|
||||
)
|
||||
else:
|
||||
logger.exception(
|
||||
f"Non-transient Playwright error for {url}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Unexpected fetch failed {url}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
finally:
|
||||
if page is not None:
|
||||
try:
|
||||
await page.close()
|
||||
except Exception as close_err:
|
||||
logger.warning(
|
||||
f"Page close error for {url}: {close_err}"
|
||||
)
|
||||
|
||||
async def route_interceptor(
|
||||
self,
|
||||
route
|
||||
):
|
||||
"""
|
||||
Block unnecessary resources to speed up crawling.
|
||||
|
||||
Image and stylesheet bytes never affect HTML
|
||||
extraction; assets are downloaded separately by
|
||||
AssetDownloader from the raw HTML. Aborting them at
|
||||
the network layer drops the per-page wire weight by
|
||||
a large factor on doc sites.
|
||||
"""
|
||||
|
||||
if route.request.resource_type in self.blocked_resource_types:
|
||||
await route.abort()
|
||||
return
|
||||
|
||||
await route.continue_()
|
||||
|
||||
async def screenshot(
|
||||
self,
|
||||
url,
|
||||
output_path
|
||||
):
|
||||
"""
|
||||
Debug screenshot utility.
|
||||
"""
|
||||
|
||||
page = None
|
||||
|
||||
try:
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
await page.goto(
|
||||
url,
|
||||
wait_until="domcontentloaded",
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
await page.screenshot(
|
||||
path=output_path,
|
||||
full_page=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Screenshot saved: "
|
||||
f"{output_path}"
|
||||
)
|
||||
|
||||
finally:
|
||||
|
||||
if page is not None:
|
||||
await page.close()
|
||||
|
||||
async def evaluate_js(
|
||||
self,
|
||||
url,
|
||||
script
|
||||
):
|
||||
"""
|
||||
Execute JS on page.
|
||||
"""
|
||||
|
||||
page = None
|
||||
|
||||
try:
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
await page.goto(
|
||||
url,
|
||||
wait_until="domcontentloaded",
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
result = await page.evaluate(
|
||||
script
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
|
||||
if page is not None:
|
||||
await page.close()
|
||||
|
||||
async def fetch_navigation_links(
|
||||
self,
|
||||
url
|
||||
):
|
||||
"""
|
||||
Extract rendered navigation links.
|
||||
"""
|
||||
|
||||
page = None
|
||||
|
||||
try:
|
||||
|
||||
page = await self.context.new_page()
|
||||
|
||||
await page.goto(
|
||||
url,
|
||||
wait_until="domcontentloaded",
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
links = await page.evaluate("""
|
||||
() => {
|
||||
return Array.from(
|
||||
document.querySelectorAll('a')
|
||||
).map(a => a.href)
|
||||
}
|
||||
""")
|
||||
|
||||
return links
|
||||
|
||||
finally:
|
||||
|
||||
if page is not None:
|
||||
await page.close()
|
||||
|
||||
def is_transient_error(self, error: Exception):
|
||||
message = str(error).lower()
|
||||
|
||||
transient_keywords = [
|
||||
"err_connection_refused",
|
||||
"err_connection_reset",
|
||||
"err_connection_closed",
|
||||
"err_timed_out",
|
||||
"timeout",
|
||||
"net::err",
|
||||
"target closed",
|
||||
"browser has been closed",
|
||||
"page crashed"
|
||||
]
|
||||
|
||||
return any(
|
||||
keyword in message
|
||||
for keyword in transient_keywords
|
||||
)
|
||||
333
crawler/sitemap_parser.py
Normal file
333
crawler/sitemap_parser.py
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import gzip
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class SitemapParser:
|
||||
"""
|
||||
Parse sitemap.xml files
|
||||
to discover documentation URLs.
|
||||
|
||||
Supports:
|
||||
- sitemap.xml
|
||||
- sitemap index
|
||||
- gzipped sitemap
|
||||
"""
|
||||
|
||||
def __init__(self, timeout=30):
|
||||
self.timeout = timeout
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
sitemap_url: str
|
||||
):
|
||||
"""
|
||||
Main entry point
|
||||
|
||||
Returns:
|
||||
-------
|
||||
List[str]
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
f"Parsing sitemap: {sitemap_url}"
|
||||
)
|
||||
|
||||
content = await self.fetch_sitemap(
|
||||
sitemap_url
|
||||
)
|
||||
|
||||
if not content:
|
||||
return []
|
||||
|
||||
root = ET.fromstring(content)
|
||||
|
||||
tag = self.clean_tag(root.tag)
|
||||
|
||||
# sitemap index
|
||||
if tag == "sitemapindex":
|
||||
|
||||
urls = await self.parse_sitemap_index(
|
||||
root
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sitemap index found {len(urls)} URLs"
|
||||
)
|
||||
|
||||
return urls
|
||||
|
||||
# regular sitemap
|
||||
elif tag == "urlset":
|
||||
|
||||
urls = self.parse_urlset(root)
|
||||
|
||||
logger.info(
|
||||
f"Sitemap contains {len(urls)} URLs"
|
||||
)
|
||||
|
||||
return urls
|
||||
|
||||
else:
|
||||
|
||||
logger.warning(
|
||||
f"Unknown sitemap type: {tag}"
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
async def fetch_sitemap(
|
||||
self,
|
||||
sitemap_url: str
|
||||
):
|
||||
"""
|
||||
Download sitemap content
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True
|
||||
) as client:
|
||||
|
||||
response = await client.get(
|
||||
sitemap_url
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
|
||||
# gzip sitemap
|
||||
if sitemap_url.endswith(".gz"):
|
||||
|
||||
content = gzip.decompress(
|
||||
content
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Failed fetching sitemap {sitemap_url}: {e}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def parse_sitemap_index(
|
||||
self,
|
||||
root
|
||||
):
|
||||
"""
|
||||
Parse sitemap index recursively
|
||||
|
||||
Example:
|
||||
|
||||
<sitemapindex>
|
||||
<sitemap>
|
||||
<loc>...</loc>
|
||||
</sitemap>
|
||||
</sitemapindex>
|
||||
"""
|
||||
|
||||
all_urls = []
|
||||
|
||||
for sitemap in root.findall(".//*"):
|
||||
|
||||
tag = self.clean_tag(sitemap.tag)
|
||||
|
||||
if tag != "loc":
|
||||
continue
|
||||
|
||||
child_sitemap_url = sitemap.text
|
||||
|
||||
if not child_sitemap_url:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Found child sitemap: {child_sitemap_url}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
urls = await self.parse(
|
||||
child_sitemap_url
|
||||
)
|
||||
|
||||
all_urls.extend(urls)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Failed child sitemap "
|
||||
f"{child_sitemap_url}: {e}"
|
||||
)
|
||||
|
||||
return all_urls
|
||||
|
||||
def parse_urlset(
|
||||
self,
|
||||
root
|
||||
):
|
||||
"""
|
||||
Parse regular sitemap URL list
|
||||
|
||||
Example:
|
||||
|
||||
<urlset>
|
||||
<url>
|
||||
<loc>...</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
"""
|
||||
|
||||
urls = []
|
||||
|
||||
for url_node in root.findall(".//*"):
|
||||
|
||||
tag = self.clean_tag(url_node.tag)
|
||||
|
||||
if tag != "loc":
|
||||
continue
|
||||
|
||||
url = url_node.text
|
||||
|
||||
if not url:
|
||||
continue
|
||||
|
||||
url = url.strip()
|
||||
|
||||
if url not in urls:
|
||||
urls.append(url)
|
||||
|
||||
return urls
|
||||
|
||||
def clean_tag(
|
||||
self,
|
||||
tag: str
|
||||
):
|
||||
"""
|
||||
Remove XML namespace
|
||||
|
||||
Example:
|
||||
{http://www.sitemaps.org/schemas/sitemap/0.9}url
|
||||
|
||||
->
|
||||
url
|
||||
"""
|
||||
|
||||
if "}" in tag:
|
||||
return tag.split("}", 1)[1]
|
||||
|
||||
return tag
|
||||
|
||||
async def discover_common_sitemaps(
|
||||
self,
|
||||
base_url: str
|
||||
):
|
||||
"""
|
||||
Try common sitemap locations
|
||||
|
||||
Returns:
|
||||
-------
|
||||
List[str]
|
||||
"""
|
||||
|
||||
common_paths = [
|
||||
"/sitemap.xml",
|
||||
"/sitemap_index.xml",
|
||||
"/sitemap-index.xml",
|
||||
"/sitemap.gz",
|
||||
"/robots.txt"
|
||||
]
|
||||
|
||||
discovered = []
|
||||
|
||||
for path in common_paths:
|
||||
|
||||
url = base_url.rstrip("/") + path
|
||||
|
||||
try:
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout
|
||||
) as client:
|
||||
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
logger.info(
|
||||
f"Found sitemap resource: {url}"
|
||||
)
|
||||
|
||||
discovered.append(url)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return discovered
|
||||
|
||||
async def parse_robots_for_sitemaps(
|
||||
self,
|
||||
robots_url: str
|
||||
):
|
||||
"""
|
||||
Extract sitemap URLs from robots.txt
|
||||
|
||||
Example:
|
||||
|
||||
Sitemap: https://example.com/sitemap.xml
|
||||
"""
|
||||
|
||||
sitemap_urls = []
|
||||
|
||||
try:
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout
|
||||
) as client:
|
||||
|
||||
response = await client.get(
|
||||
robots_url
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
text = response.text
|
||||
|
||||
for line in text.splitlines():
|
||||
|
||||
line = line.strip()
|
||||
|
||||
if line.lower().startswith(
|
||||
"sitemap:"
|
||||
):
|
||||
|
||||
sitemap_url = line.split(
|
||||
":",
|
||||
1
|
||||
)[1].strip()
|
||||
|
||||
sitemap_urls.append(
|
||||
sitemap_url
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Found {len(sitemap_urls)} "
|
||||
f"sitemaps in robots.txt"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Failed parsing robots.txt "
|
||||
f"{robots_url}: {e}"
|
||||
)
|
||||
|
||||
return sitemap_urls
|
||||
329
crawler/sphinx_discovery.py
Normal file
329
crawler/sphinx_discovery.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
def _ensure_soup(html_or_soup):
|
||||
if hasattr(html_or_soup, "select_one") and hasattr(
|
||||
html_or_soup, "find_all"
|
||||
):
|
||||
return html_or_soup
|
||||
return BeautifulSoup(html_or_soup, "lxml")
|
||||
|
||||
|
||||
class SphinxDiscovery:
|
||||
"""
|
||||
Discover hidden / structured pages from Sphinx-like docs.
|
||||
|
||||
Sources:
|
||||
- searchindex.js
|
||||
- genindex.html
|
||||
- global index pages
|
||||
- toctree/sidebar links
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url_manager,
|
||||
timeout=30
|
||||
):
|
||||
self.url_manager = url_manager
|
||||
self.timeout = timeout
|
||||
|
||||
async def discover_from_base(
|
||||
self,
|
||||
base_url: str
|
||||
):
|
||||
"""
|
||||
Main discovery entry point.
|
||||
"""
|
||||
|
||||
discovered = set()
|
||||
|
||||
candidates = self.common_sphinx_urls(
|
||||
base_url
|
||||
)
|
||||
|
||||
for url in candidates:
|
||||
urls = await self.discover_from_url(url)
|
||||
discovered.update(urls)
|
||||
|
||||
logger.info(
|
||||
f"Sphinx discovery found {len(discovered)} URLs from {base_url}"
|
||||
)
|
||||
|
||||
return sorted(discovered)
|
||||
|
||||
def common_sphinx_urls(
|
||||
self,
|
||||
base_url: str
|
||||
):
|
||||
"""
|
||||
Common Sphinx-generated files.
|
||||
"""
|
||||
|
||||
base = base_url.rstrip("/") + "/"
|
||||
|
||||
return [
|
||||
urljoin(base, "searchindex.js"),
|
||||
urljoin(base, "genindex.html"),
|
||||
urljoin(base, "py-modindex.html"),
|
||||
urljoin(base, "search.html"),
|
||||
urljoin(base, "contents.html"),
|
||||
urljoin(base, "index.html"),
|
||||
]
|
||||
|
||||
async def discover_from_url(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Dispatch discovery by file type.
|
||||
"""
|
||||
|
||||
if url.endswith("searchindex.js"):
|
||||
return await self.parse_searchindex(url)
|
||||
|
||||
if url.endswith(".html"):
|
||||
return await self.parse_html_index(url)
|
||||
|
||||
return []
|
||||
|
||||
async def fetch_text(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code >= 400:
|
||||
return None
|
||||
|
||||
return response.text
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Sphinx discovery fetch failed {url}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def parse_searchindex(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Parse Sphinx searchindex.js.
|
||||
|
||||
Typical format:
|
||||
Search.setIndex({...})
|
||||
"""
|
||||
|
||||
text = await self.fetch_text(url)
|
||||
|
||||
if not text:
|
||||
return []
|
||||
|
||||
try:
|
||||
payload = self.extract_searchindex_json(text)
|
||||
|
||||
if not payload:
|
||||
return []
|
||||
|
||||
docnames = payload.get("docnames", [])
|
||||
|
||||
base_url = url.rsplit("/", 1)[0] + "/"
|
||||
|
||||
discovered = set()
|
||||
|
||||
for docname in docnames:
|
||||
page_url = urljoin(
|
||||
base_url,
|
||||
docname + ".html"
|
||||
)
|
||||
|
||||
normalized = self.url_manager.normalize(page_url)
|
||||
|
||||
if (
|
||||
normalized
|
||||
and self.url_manager.is_allowed(normalized)
|
||||
):
|
||||
discovered.add(normalized)
|
||||
|
||||
logger.info(
|
||||
f"Parsed {len(discovered)} URLs from searchindex: {url}"
|
||||
)
|
||||
|
||||
return sorted(discovered)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed parsing searchindex {url}: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
def extract_searchindex_json(
|
||||
self,
|
||||
text: str
|
||||
):
|
||||
"""
|
||||
Extract JSON-like object from Sphinx searchindex.js.
|
||||
"""
|
||||
|
||||
match = re.search(
|
||||
r"Search\.setIndex\((.*)\)\s*;?\s*$",
|
||||
text,
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
if not match:
|
||||
return None
|
||||
|
||||
raw = match.group(1)
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback for slightly non-standard JS object
|
||||
raw = raw.replace("'", '"')
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"searchindex.js is not valid JSON after normalization."
|
||||
)
|
||||
return None
|
||||
|
||||
async def parse_html_index(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Parse Sphinx index-like HTML files.
|
||||
"""
|
||||
|
||||
html = await self.fetch_text(url)
|
||||
|
||||
if not html:
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
discovered = set()
|
||||
|
||||
selectors = [
|
||||
"a.reference.internal",
|
||||
".toctree-wrapper a",
|
||||
".wy-menu a",
|
||||
".sphinxsidebar a",
|
||||
".globaltoc a",
|
||||
".localtoc a",
|
||||
"nav a",
|
||||
"a[href]"
|
||||
]
|
||||
|
||||
for selector in selectors:
|
||||
for a in soup.select(selector):
|
||||
href = a.get("href")
|
||||
|
||||
resolved = self.url_manager.resolve(
|
||||
url,
|
||||
href
|
||||
)
|
||||
|
||||
if not resolved:
|
||||
continue
|
||||
|
||||
if not self.url_manager.is_allowed(resolved):
|
||||
continue
|
||||
|
||||
discovered.add(resolved)
|
||||
|
||||
logger.info(
|
||||
f"Parsed {len(discovered)} URLs from HTML index: {url}"
|
||||
)
|
||||
|
||||
return sorted(discovered)
|
||||
|
||||
async def discover_from_rendered_html(
|
||||
self,
|
||||
base_url: str,
|
||||
html
|
||||
):
|
||||
"""
|
||||
Discover Sphinx links from already-rendered page HTML.
|
||||
Accepts either a string or a parsed soup.
|
||||
"""
|
||||
|
||||
soup = _ensure_soup(html)
|
||||
|
||||
discovered = set()
|
||||
|
||||
selectors = [
|
||||
"a.reference.internal",
|
||||
".toctree-wrapper a.reference.internal",
|
||||
".section a.reference.internal",
|
||||
".body a.reference.internal",
|
||||
".document a.reference.internal",
|
||||
".wy-menu a",
|
||||
".sphinxsidebar a",
|
||||
".globaltoc a",
|
||||
".localtoc a",
|
||||
"link[rel='next']",
|
||||
"link[rel='prev']",
|
||||
"link[rel='up']"
|
||||
]
|
||||
|
||||
for selector in selectors:
|
||||
for node in soup.select(selector):
|
||||
href = node.get("href")
|
||||
|
||||
if not href:
|
||||
continue
|
||||
|
||||
resolved = self.url_manager.resolve(
|
||||
base_url,
|
||||
href
|
||||
)
|
||||
|
||||
if not resolved:
|
||||
continue
|
||||
|
||||
if not self.url_manager.is_allowed(resolved):
|
||||
continue
|
||||
|
||||
discovered.add(resolved)
|
||||
|
||||
return sorted(discovered)
|
||||
|
||||
def build_site_graph_edges(
|
||||
self,
|
||||
source_url: str,
|
||||
target_urls: list[str]
|
||||
):
|
||||
"""
|
||||
Prepare graph edges.
|
||||
"""
|
||||
|
||||
edges = []
|
||||
|
||||
for target in target_urls:
|
||||
edges.append({
|
||||
"source": source_url,
|
||||
"target": target,
|
||||
"type": "sphinx_internal_link"
|
||||
})
|
||||
|
||||
return edges
|
||||
328
crawler/url_manager.py
Normal file
328
crawler/url_manager.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
from urllib.parse import (
|
||||
urlparse,
|
||||
urljoin,
|
||||
parse_qs,
|
||||
urlencode
|
||||
)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class URLManager:
|
||||
"""
|
||||
Production-ready URL manager.
|
||||
|
||||
Handles:
|
||||
- normalization
|
||||
- deduplication
|
||||
- filtering
|
||||
- crawl scope
|
||||
- asset filtering
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed_domains,
|
||||
allowed_schemes=None,
|
||||
blocked_extensions=None,
|
||||
remove_query_params=True
|
||||
):
|
||||
self.allowed_domains = set(
|
||||
allowed_domains
|
||||
)
|
||||
|
||||
self.allowed_schemes = (
|
||||
allowed_schemes
|
||||
or {"http", "https"}
|
||||
)
|
||||
|
||||
self.remove_query_params = (
|
||||
remove_query_params
|
||||
)
|
||||
|
||||
self.blocked_extensions = (
|
||||
blocked_extensions
|
||||
or {
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".svg",
|
||||
".webp",
|
||||
".pdf",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".doc",
|
||||
".docx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".mp4",
|
||||
".mp3",
|
||||
".avi",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".eot"
|
||||
}
|
||||
)
|
||||
|
||||
def normalize(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Normalize URL consistently.
|
||||
"""
|
||||
|
||||
if not url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
# Remove fragments
|
||||
parsed = parsed._replace(
|
||||
fragment=""
|
||||
)
|
||||
|
||||
# Normalize scheme/domain
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
netloc = parsed.netloc.lower()
|
||||
|
||||
# Remove default ports
|
||||
netloc = netloc.replace(
|
||||
":80",
|
||||
""
|
||||
).replace(
|
||||
":443",
|
||||
""
|
||||
)
|
||||
|
||||
# Normalize path
|
||||
path = parsed.path or "/"
|
||||
|
||||
if path != "/" and path.endswith("/"):
|
||||
|
||||
path = path.rstrip("/")
|
||||
|
||||
# Remove tracking query params
|
||||
query = ""
|
||||
|
||||
if (
|
||||
not self.remove_query_params
|
||||
and parsed.query
|
||||
):
|
||||
query = parsed.query
|
||||
|
||||
normalized = parsed._replace(
|
||||
scheme=scheme,
|
||||
netloc=netloc,
|
||||
path=path,
|
||||
query=query
|
||||
)
|
||||
|
||||
return normalized.geturl()
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
base_url: str,
|
||||
href: str
|
||||
):
|
||||
"""
|
||||
Resolve relative URL.
|
||||
"""
|
||||
|
||||
if not href:
|
||||
return None
|
||||
|
||||
href = href.strip()
|
||||
|
||||
# Ignore anchors
|
||||
if href.startswith("#"):
|
||||
return None
|
||||
|
||||
# Ignore JS/mail links
|
||||
ignored = [
|
||||
"javascript:",
|
||||
"mailto:",
|
||||
"tel:"
|
||||
]
|
||||
|
||||
if any(
|
||||
href.startswith(prefix)
|
||||
for prefix in ignored
|
||||
):
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
resolved = urljoin(
|
||||
base_url,
|
||||
href
|
||||
)
|
||||
|
||||
return self.normalize(
|
||||
resolved
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"URL resolve failed "
|
||||
f"{href}: {e}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def is_allowed(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Check crawl scope.
|
||||
"""
|
||||
|
||||
if not url:
|
||||
return False
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
# Scheme check
|
||||
if (
|
||||
parsed.scheme
|
||||
not in self.allowed_schemes
|
||||
):
|
||||
|
||||
return False
|
||||
|
||||
domain = parsed.netloc.lower()
|
||||
|
||||
# Domain check
|
||||
if not any(
|
||||
domain == allowed or domain.endswith("." + allowed)
|
||||
for allowed in self.allowed_domains
|
||||
):
|
||||
return False
|
||||
|
||||
# Extension filter
|
||||
extension = Path(
|
||||
parsed.path
|
||||
).suffix.lower()
|
||||
|
||||
if extension in self.blocked_extensions:
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def should_enqueue(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Determine whether URL
|
||||
should be crawled.
|
||||
"""
|
||||
|
||||
normalized = self.normalize(url)
|
||||
|
||||
if not normalized:
|
||||
|
||||
return False
|
||||
|
||||
if not self.is_allowed(normalized):
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def extract_domain(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Extract domain.
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
return parsed.netloc.lower()
|
||||
|
||||
def extract_path(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Extract path.
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
return parsed.path
|
||||
|
||||
def remove_query_tracking(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Remove tracking params.
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
query = parse_qs(
|
||||
parsed.query
|
||||
)
|
||||
|
||||
blocked_params = {
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"fbclid",
|
||||
"gclid"
|
||||
}
|
||||
|
||||
clean_query = {
|
||||
k: v
|
||||
for k, v in query.items()
|
||||
if k not in blocked_params
|
||||
}
|
||||
|
||||
encoded_query = urlencode(
|
||||
clean_query,
|
||||
doseq=True
|
||||
)
|
||||
|
||||
parsed = parsed._replace(
|
||||
query=encoded_query
|
||||
)
|
||||
|
||||
return parsed.geturl()
|
||||
|
||||
def is_binary_url(self, url: str):
|
||||
parsed = urlparse(url)
|
||||
extension = Path(parsed.path).suffix.lower()
|
||||
|
||||
binary_extensions = {
|
||||
".pdf",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".doc",
|
||||
".docx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".zip"
|
||||
}
|
||||
|
||||
return extension in binary_extensions
|
||||
|
||||
def stats(self):
|
||||
return {
|
||||
"allowed_domains": list(self.allowed_domains),
|
||||
"blocked_extensions": list(self.blocked_extensions),
|
||||
}
|
||||
0
extractor/__init__.py
Normal file
0
extractor/__init__.py
Normal file
420
extractor/asset_downloader.py
Normal file
420
extractor/asset_downloader.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from utils.logger import logger
|
||||
import os
|
||||
|
||||
|
||||
def _ensure_soup(html_or_soup):
|
||||
if hasattr(html_or_soup, "select_one") and hasattr(
|
||||
html_or_soup, "find_all"
|
||||
):
|
||||
return html_or_soup
|
||||
return BeautifulSoup(html_or_soup, "lxml")
|
||||
|
||||
class AssetDownloader:
|
||||
"""
|
||||
Download assets from documentation pages.
|
||||
|
||||
Supported:
|
||||
- images
|
||||
- css
|
||||
- js
|
||||
- fonts
|
||||
- downloadable files
|
||||
|
||||
Features:
|
||||
- deduplicate downloads
|
||||
- async download
|
||||
- preserve structure
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_dir="output/assets",
|
||||
timeout=30,
|
||||
max_concurrency=10,
|
||||
http_client=None
|
||||
):
|
||||
self.output_dir = Path(output_dir)
|
||||
|
||||
self.timeout = timeout
|
||||
|
||||
# downloaded maps url -> local Path so repeat lookups
|
||||
# short-circuit without touching the filesystem
|
||||
self.downloaded = {}
|
||||
|
||||
# Shared httpx client (keep-alive + optional http2). If
|
||||
# the caller didn't pass one we create our own — but
|
||||
# a shared client across pages is what actually buys
|
||||
# the speedup.
|
||||
self._owns_client = http_client is None
|
||||
if http_client is None:
|
||||
try:
|
||||
import h2 # noqa: F401
|
||||
_http2 = True
|
||||
except ImportError:
|
||||
_http2 = False
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
http2=_http2,
|
||||
limits=httpx.Limits(
|
||||
max_connections=max_concurrency * 2,
|
||||
max_keepalive_connections=max_concurrency * 2,
|
||||
),
|
||||
)
|
||||
self.client = http_client
|
||||
|
||||
self.semaphore = asyncio.Semaphore(max_concurrency)
|
||||
|
||||
self.allowed_extensions = {
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".svg",
|
||||
".webp",
|
||||
".css",
|
||||
".js",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".eot",
|
||||
".pdf",
|
||||
".zip",
|
||||
".ico",
|
||||
".bmp",
|
||||
".avif",
|
||||
".map",
|
||||
".json",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".doc",
|
||||
".docx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".csv"
|
||||
}
|
||||
|
||||
async def download_assets_from_html(
|
||||
self,
|
||||
base_url: str,
|
||||
html
|
||||
):
|
||||
"""
|
||||
Parse HTML and download all assets. Accepts either an
|
||||
HTML string or a BS4 soup/Tag so process_page can pass
|
||||
an already-parsed tree.
|
||||
"""
|
||||
|
||||
soup = _ensure_soup(html)
|
||||
|
||||
asset_urls = set()
|
||||
|
||||
# Images
|
||||
for img in soup.find_all("img", src=True):
|
||||
|
||||
src = img["src"].strip()
|
||||
|
||||
full_url = urljoin(base_url, src)
|
||||
|
||||
asset_urls.add(full_url)
|
||||
|
||||
# CSS
|
||||
for link in soup.find_all("link", href=True):
|
||||
|
||||
href = link["href"].strip()
|
||||
|
||||
rel = link.get("rel", [])
|
||||
|
||||
if "stylesheet" in rel:
|
||||
asset_urls.add(
|
||||
urljoin(base_url, href)
|
||||
)
|
||||
|
||||
# JS
|
||||
for script in soup.find_all(
|
||||
"script",
|
||||
src=True
|
||||
):
|
||||
|
||||
src = script["src"].strip()
|
||||
|
||||
asset_urls.add(
|
||||
urljoin(base_url, src)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Found {len(asset_urls)} assets"
|
||||
)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(self._download_safe(u) for u in asset_urls),
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
downloaded_assets = [
|
||||
{"url": url, "local_path": str(path)}
|
||||
for url, path in results
|
||||
if path is not None
|
||||
]
|
||||
|
||||
return downloaded_assets
|
||||
|
||||
async def _download_safe(self, asset_url):
|
||||
try:
|
||||
local_path = await self.download_asset(asset_url)
|
||||
return asset_url, local_path
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Asset download failed {asset_url}: {e}"
|
||||
)
|
||||
return asset_url, None
|
||||
|
||||
async def download_asset(
|
||||
self,
|
||||
asset_url: str
|
||||
):
|
||||
"""
|
||||
Download single asset.
|
||||
"""
|
||||
|
||||
asset_url = self.normalize_url(asset_url)
|
||||
|
||||
cached = self.downloaded.get(asset_url)
|
||||
if cached is not None:
|
||||
logger.debug(f"Already downloaded: {asset_url}")
|
||||
return cached
|
||||
|
||||
parsed = urlparse(asset_url)
|
||||
|
||||
extension = Path(parsed.path).suffix.lower()
|
||||
|
||||
if extension not in self.allowed_extensions:
|
||||
|
||||
logger.debug(
|
||||
f"Skipped unsupported asset: "
|
||||
f"{asset_url}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
local_path = self.url_to_local_path(
|
||||
asset_url
|
||||
)
|
||||
|
||||
# Skip re-downloading assets that are already on disk
|
||||
# from a prior run. Saves a HEAD+GET roundtrip per
|
||||
# cache-bustable asset on resume.
|
||||
if local_path.exists():
|
||||
self.downloaded[asset_url] = local_path
|
||||
return local_path
|
||||
|
||||
local_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
try:
|
||||
async with self.semaphore:
|
||||
response = await self.client.get(asset_url)
|
||||
response.raise_for_status()
|
||||
|
||||
async with aiofiles.open(
|
||||
local_path,
|
||||
"wb"
|
||||
) as f:
|
||||
await f.write(response.content)
|
||||
|
||||
self.downloaded[asset_url] = local_path
|
||||
|
||||
logger.debug(
|
||||
f"Downloaded asset: {local_path}"
|
||||
)
|
||||
|
||||
return local_path
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Failed asset download "
|
||||
f"{asset_url}: {e}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def aclose(self):
|
||||
if self._owns_client:
|
||||
await self.client.aclose()
|
||||
|
||||
def url_to_local_path(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Convert asset URL -> local path.
|
||||
|
||||
Example:
|
||||
|
||||
https://api.intra-mart.jp/assets/logo.png
|
||||
|
||||
=>
|
||||
output/assets/api.intra-mart.jp/assets/logo.png
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
domain = parsed.netloc
|
||||
|
||||
path = parsed.path.strip("/")
|
||||
|
||||
if not path:
|
||||
|
||||
filename = self.hash_url(url)
|
||||
|
||||
path = f"unknown/{filename}"
|
||||
|
||||
local_path = (
|
||||
self.output_dir /
|
||||
domain /
|
||||
path
|
||||
)
|
||||
|
||||
return local_path
|
||||
|
||||
def hash_url(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Generate stable filename hash
|
||||
"""
|
||||
|
||||
return hashlib.md5(
|
||||
url.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
def normalize_url(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Remove fragments and query strings.
|
||||
|
||||
Query strings are stripped so that cache-busted URLs
|
||||
(e.g. logo.png?v=1 vs logo.png?v=2) deduplicate to the
|
||||
same local file path -- avoiding repeated downloads
|
||||
that overwrite the same file and avoiding stale dedup
|
||||
keys that point to a path written by a different URL.
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
clean = parsed._replace(fragment="", query="")
|
||||
|
||||
return clean.geturl()
|
||||
|
||||
def rewrite_asset_links(
|
||||
self,
|
||||
html,
|
||||
base_url: str,
|
||||
current_output_path: Path
|
||||
):
|
||||
"""
|
||||
Rewrite HTML asset links to local filesystem paths.
|
||||
Accepts either an HTML string or a BS4 soup/Tag.
|
||||
Returns the rewritten HTML as a string.
|
||||
"""
|
||||
|
||||
soup = _ensure_soup(html)
|
||||
|
||||
|
||||
|
||||
# Rewrite image src
|
||||
for img in soup.find_all("img", src=True):
|
||||
|
||||
src = img["src"]
|
||||
|
||||
full_url = urljoin(base_url, src)
|
||||
|
||||
if Path(urlparse(full_url).path).suffix.lower() not in self.allowed_extensions:
|
||||
continue
|
||||
|
||||
relative_path = self.make_relative_asset_path(
|
||||
full_url,
|
||||
current_output_path
|
||||
)
|
||||
|
||||
img["src"] = relative_path
|
||||
|
||||
# Rewrite CSS href -- ONLY rewrite tags we actually
|
||||
# download (rel=stylesheet). Other <link> tags
|
||||
# (canonical, alternate, icon, preload, ...) point to
|
||||
# files we never fetched, so rewriting their href
|
||||
# would produce dead local references.
|
||||
for link in soup.find_all(
|
||||
"link",
|
||||
href=True
|
||||
):
|
||||
|
||||
rel = link.get("rel", [])
|
||||
|
||||
if "stylesheet" not in rel:
|
||||
continue
|
||||
|
||||
href = link["href"]
|
||||
|
||||
full_url = urljoin(base_url, href)
|
||||
|
||||
if Path(urlparse(full_url).path).suffix.lower() not in self.allowed_extensions:
|
||||
continue
|
||||
|
||||
relative_path = self.make_relative_asset_path(
|
||||
full_url,
|
||||
current_output_path
|
||||
)
|
||||
|
||||
link["href"] = relative_path
|
||||
|
||||
# Rewrite JS src
|
||||
for script in soup.find_all(
|
||||
"script",
|
||||
src=True
|
||||
):
|
||||
|
||||
src = script["src"]
|
||||
|
||||
full_url = urljoin(base_url, src)
|
||||
|
||||
if Path(urlparse(full_url).path).suffix.lower() not in self.allowed_extensions:
|
||||
continue
|
||||
|
||||
relative_path = self.make_relative_asset_path(
|
||||
full_url,
|
||||
current_output_path
|
||||
)
|
||||
|
||||
script["src"] = relative_path
|
||||
|
||||
return str(soup)
|
||||
|
||||
def make_relative_asset_path(
|
||||
self,
|
||||
asset_url: str,
|
||||
current_output_path: Path
|
||||
):
|
||||
local_path = self.url_to_local_path(asset_url)
|
||||
|
||||
relative = os.path.relpath(
|
||||
local_path,
|
||||
current_output_path.parent
|
||||
)
|
||||
|
||||
return relative.replace("\\", "/")
|
||||
227
extractor/html_cleaner.py
Normal file
227
extractor/html_cleaner.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
from bs4 import BeautifulSoup
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
def _ensure_soup(html_or_soup):
|
||||
"""
|
||||
Accept either a BeautifulSoup/Tag or an HTML string and
|
||||
return a BeautifulSoup-like object. Cheap when input is
|
||||
already parsed (this is the whole point — avoid reparsing).
|
||||
"""
|
||||
if hasattr(html_or_soup, "select_one") and hasattr(
|
||||
html_or_soup, "find_all"
|
||||
):
|
||||
return html_or_soup
|
||||
return BeautifulSoup(html_or_soup, "lxml")
|
||||
|
||||
|
||||
class HTMLCleaner:
|
||||
"""
|
||||
Clean extracted HTML
|
||||
before markdown conversion.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
remove_selectors
|
||||
):
|
||||
self.remove_selectors = remove_selectors
|
||||
|
||||
def clean(
|
||||
self,
|
||||
html
|
||||
):
|
||||
"""
|
||||
Clean HTML in place when given a soup; otherwise parse
|
||||
the string, clean, and return the string. Returning
|
||||
the same type the caller passed lets process_page hold
|
||||
a soup across the whole pipeline without re-parsing.
|
||||
"""
|
||||
return_string = isinstance(html, (str, bytes))
|
||||
soup = _ensure_soup(html)
|
||||
|
||||
self.remove_unwanted_nodes(soup)
|
||||
|
||||
self.normalize_code_blocks(soup)
|
||||
|
||||
self.normalize_tables(soup)
|
||||
|
||||
self.fix_relative_links(soup)
|
||||
|
||||
self.remove_empty_tags(soup)
|
||||
|
||||
self.unwrap_redundant_tags(soup)
|
||||
|
||||
return str(soup) if return_string else soup
|
||||
|
||||
def remove_unwanted_nodes(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Remove navigation/UI noise.
|
||||
"""
|
||||
|
||||
for selector in self.remove_selectors:
|
||||
|
||||
try:
|
||||
|
||||
for tag in soup.select(selector):
|
||||
|
||||
tag.decompose()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(
|
||||
f"Remove selector failed "
|
||||
f"{selector}: {e}"
|
||||
)
|
||||
|
||||
additional_noise = [
|
||||
".sidebar",
|
||||
".toc",
|
||||
".breadcrumb",
|
||||
".pagination",
|
||||
".edit-page",
|
||||
".theme-doc-version-badge",
|
||||
".header",
|
||||
".footer",
|
||||
".ads",
|
||||
".search",
|
||||
".search-box",
|
||||
".navigation"
|
||||
]
|
||||
|
||||
for selector in additional_noise:
|
||||
|
||||
for tag in soup.select(selector):
|
||||
|
||||
tag.decompose()
|
||||
|
||||
def normalize_code_blocks(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Normalize code blocks
|
||||
for better markdown conversion.
|
||||
"""
|
||||
|
||||
for pre in soup.find_all("pre"):
|
||||
|
||||
code = pre.find("code")
|
||||
|
||||
if not code:
|
||||
continue
|
||||
|
||||
classes = code.get("class", [])
|
||||
|
||||
language = None
|
||||
|
||||
for cls in classes:
|
||||
|
||||
if cls.startswith("language-"):
|
||||
|
||||
language = cls.replace(
|
||||
"language-",
|
||||
""
|
||||
)
|
||||
|
||||
if language:
|
||||
|
||||
pre["data-language"] = language
|
||||
|
||||
def normalize_tables(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Improve HTML table structure.
|
||||
"""
|
||||
|
||||
for table in soup.find_all("table"):
|
||||
|
||||
if not table.find("thead"):
|
||||
|
||||
first_row = table.find("tr")
|
||||
|
||||
if first_row:
|
||||
|
||||
thead = soup.new_tag(
|
||||
"thead"
|
||||
)
|
||||
|
||||
first_row.extract()
|
||||
|
||||
thead.append(first_row)
|
||||
|
||||
table.insert(0, thead)
|
||||
|
||||
def fix_relative_links(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Remove javascript links.
|
||||
"""
|
||||
|
||||
for a in soup.find_all(
|
||||
"a",
|
||||
href=True
|
||||
):
|
||||
|
||||
href = a["href"].strip()
|
||||
|
||||
if href.startswith(
|
||||
"javascript:"
|
||||
):
|
||||
|
||||
del a["href"]
|
||||
|
||||
def remove_empty_tags(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Remove empty useless tags.
|
||||
"""
|
||||
|
||||
removable = [
|
||||
"div",
|
||||
"span",
|
||||
"p"
|
||||
]
|
||||
|
||||
for tag_name in removable:
|
||||
|
||||
for tag in soup.find_all(tag_name):
|
||||
|
||||
if tag.get_text(strip=True):
|
||||
continue
|
||||
|
||||
if tag.find():
|
||||
continue
|
||||
|
||||
tag.decompose()
|
||||
|
||||
def unwrap_redundant_tags(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Flatten unnecessary wrappers.
|
||||
"""
|
||||
|
||||
unwrap_tags = [
|
||||
"span"
|
||||
]
|
||||
|
||||
for tag_name in unwrap_tags:
|
||||
|
||||
for tag in soup.find_all(tag_name):
|
||||
|
||||
if tag.attrs:
|
||||
continue
|
||||
|
||||
tag.unwrap()
|
||||
207
extractor/html_extractor.py
Normal file
207
extractor/html_extractor.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
from bs4 import BeautifulSoup
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
def _ensure_soup(html_or_soup):
|
||||
if hasattr(html_or_soup, "select_one") and hasattr(
|
||||
html_or_soup, "find_all"
|
||||
):
|
||||
return html_or_soup
|
||||
return BeautifulSoup(html_or_soup, "lxml")
|
||||
|
||||
|
||||
class HTMLExtractor:
|
||||
"""
|
||||
Extract main documentation content
|
||||
from rendered HTML.
|
||||
"""
|
||||
|
||||
def __init__(self, selectors):
|
||||
self.selectors = selectors
|
||||
|
||||
def extract_main_content(
|
||||
self,
|
||||
html
|
||||
):
|
||||
"""
|
||||
Extract the most relevant content block.
|
||||
"""
|
||||
result = self.extract_with_status(html)
|
||||
return result["html"]
|
||||
|
||||
def extract_with_status(self, html):
|
||||
"""
|
||||
Like extract_main_content but reports whether a
|
||||
configured selector matched. Accepts either an HTML
|
||||
string or a BeautifulSoup/Tag so process_page can
|
||||
avoid re-parsing.
|
||||
|
||||
The returned dict now also includes ``node`` — the
|
||||
matched BS4 element — so downstream stages (asset
|
||||
downloader, asset link rewriter) can operate directly
|
||||
on the parsed tree instead of re-parsing ``html``.
|
||||
"""
|
||||
|
||||
soup = _ensure_soup(html)
|
||||
|
||||
self.remove_noise(soup)
|
||||
|
||||
for selector in self.selectors:
|
||||
try:
|
||||
node = soup.select_one(selector)
|
||||
|
||||
if node:
|
||||
text_length = len(
|
||||
node.get_text(strip=True)
|
||||
)
|
||||
|
||||
if text_length < 100:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Main content found "
|
||||
f"with selector: {selector}"
|
||||
)
|
||||
|
||||
return {
|
||||
"node": node,
|
||||
"html": str(node),
|
||||
"matched": True,
|
||||
"selector": selector,
|
||||
"text_length": text_length,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Selector failed {selector}: {e}"
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"No selector matched. Using fallback extraction."
|
||||
)
|
||||
|
||||
fallback = self.fallback_extract(soup)
|
||||
|
||||
return {
|
||||
"node": fallback,
|
||||
"html": str(fallback),
|
||||
"matched": False,
|
||||
"selector": None,
|
||||
"text_length": len(fallback.get_text(strip=True))
|
||||
if hasattr(fallback, "get_text")
|
||||
else 0,
|
||||
}
|
||||
|
||||
def remove_noise(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Remove obvious noise
|
||||
before extraction.
|
||||
"""
|
||||
|
||||
noise_tags = [
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
"iframe",
|
||||
"svg"
|
||||
]
|
||||
|
||||
for tag_name in noise_tags:
|
||||
|
||||
for tag in soup.find_all(tag_name):
|
||||
|
||||
tag.decompose()
|
||||
|
||||
def fallback_extract(
|
||||
self,
|
||||
soup
|
||||
):
|
||||
"""
|
||||
Heuristic fallback extraction.
|
||||
"""
|
||||
|
||||
candidates = []
|
||||
|
||||
possible_selectors = [
|
||||
"main",
|
||||
"article",
|
||||
".content",
|
||||
".document",
|
||||
".markdown-body",
|
||||
".theme-doc-markdown",
|
||||
".rst-content",
|
||||
".page-content"
|
||||
]
|
||||
|
||||
for selector in possible_selectors:
|
||||
|
||||
for node in soup.select(selector):
|
||||
|
||||
score = self.calculate_score(node)
|
||||
|
||||
candidates.append(
|
||||
(score, node)
|
||||
)
|
||||
|
||||
if candidates:
|
||||
|
||||
candidates.sort(
|
||||
key=lambda x: x[0],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
best = candidates[0][1]
|
||||
|
||||
logger.info(
|
||||
"Fallback extraction selected "
|
||||
"best candidate."
|
||||
)
|
||||
|
||||
return best
|
||||
|
||||
# Final fallback = body
|
||||
body = soup.body
|
||||
|
||||
if body:
|
||||
return body
|
||||
|
||||
return soup
|
||||
|
||||
def calculate_score(
|
||||
self,
|
||||
node
|
||||
):
|
||||
"""
|
||||
Score content relevance.
|
||||
"""
|
||||
|
||||
text = node.get_text(" ", strip=True)
|
||||
|
||||
text_length = len(text)
|
||||
|
||||
paragraph_count = len(
|
||||
node.find_all("p")
|
||||
)
|
||||
|
||||
code_blocks = len(
|
||||
node.find_all(["pre", "code"])
|
||||
)
|
||||
|
||||
headings = len(
|
||||
node.find_all(
|
||||
["h1", "h2", "h3"]
|
||||
)
|
||||
)
|
||||
|
||||
score = (
|
||||
text_length
|
||||
+ paragraph_count * 50
|
||||
+ code_blocks * 100
|
||||
+ headings * 30
|
||||
)
|
||||
|
||||
return score
|
||||
815
main.py
Normal file
815
main.py
Normal file
|
|
@ -0,0 +1,815 @@
|
|||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from crawler.http_client import HTTPClient
|
||||
from crawler.playwright_client import PlaywrightClient
|
||||
from crawler.url_manager import URLManager
|
||||
|
||||
from converter.markdown_converter import MarkdownConverter
|
||||
|
||||
from extractor.html_cleaner import HTMLCleaner
|
||||
from extractor.html_extractor import HTMLExtractor
|
||||
|
||||
from models.crawl_result import CrawlResult
|
||||
|
||||
from storage.file_writer import FileWriter
|
||||
from storage.metadata_db import MetadataDB
|
||||
from storage.path_mapper import PathMapper
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
from extractor.asset_downloader import AssetDownloader
|
||||
from crawler.sphinx_discovery import SphinxDiscovery
|
||||
from crawler.navigation_parser import NavigationParser
|
||||
|
||||
START_URLS = [
|
||||
"https://api.intra-mart.jp/iap/",
|
||||
"https://document.intra-mart.jp/library/"
|
||||
]
|
||||
|
||||
|
||||
OUTPUT_DIR = "output"
|
||||
|
||||
|
||||
ALLOWED_DOMAINS = [
|
||||
"api.intra-mart.jp",
|
||||
"document.intra-mart.jp"
|
||||
]
|
||||
|
||||
|
||||
REMOVE_SELECTORS = [
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
|
||||
".sidebar-ad",
|
||||
|
||||
".navigation-footer",
|
||||
|
||||
".breadcrumb",
|
||||
|
||||
".toc",
|
||||
|
||||
".page-footer",
|
||||
|
||||
".header",
|
||||
|
||||
".search",
|
||||
|
||||
".ads"
|
||||
]
|
||||
|
||||
|
||||
CONTENT_SELECTORS = [
|
||||
"main",
|
||||
|
||||
"article",
|
||||
|
||||
".content",
|
||||
|
||||
".document",
|
||||
|
||||
".markdown-body",
|
||||
|
||||
".theme-doc-markdown",
|
||||
|
||||
"#content",
|
||||
|
||||
# Javadoc (api.intra-mart.jp/iap/javadoc/...) — static
|
||||
# pages, by far the bulk of the queue. Matching this
|
||||
# selector lets the HTTP fast-path skip Playwright.
|
||||
"div.contentContainer",
|
||||
|
||||
# apilist-ssjs (api.intra-mart.jp/iap/apilist-ssjs/...)
|
||||
# wraps its body content in <div class="main">. Use a
|
||||
# body-scoped selector so we don't match `.main`
|
||||
# elements nested inside navigation widgets.
|
||||
"body > div.main",
|
||||
|
||||
# document.intra-mart.jp/library/... — most user-guide
|
||||
# pages put their content in a single top-level
|
||||
# <div class="container">.
|
||||
"body > div.container",
|
||||
]
|
||||
|
||||
|
||||
MAX_PAGES_PER_RUN = 100000
|
||||
MAX_ATTEMPTS = 3
|
||||
BROWSER_RECYCLE_EVERY = 500
|
||||
|
||||
|
||||
class _PermanentFetchFailure(Exception):
|
||||
"""Raised when re-fetching the URL cannot succeed (4xx)."""
|
||||
|
||||
# Number of concurrent page workers. Each worker shares the
|
||||
# single PlaywrightContext + single httpx client; only DB
|
||||
# writes serialize through self.db_lock. The bottleneck is
|
||||
# network I/O, so this is the multiplier on throughput.
|
||||
CONCURRENCY = 8
|
||||
|
||||
class DocumentationCrawler:
|
||||
"""
|
||||
Main crawler orchestrator.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = PlaywrightClient(
|
||||
headless=True,
|
||||
content_selectors=CONTENT_SELECTORS,
|
||||
)
|
||||
|
||||
self.http_client = HTTPClient()
|
||||
|
||||
self.url_manager = URLManager(
|
||||
allowed_domains=ALLOWED_DOMAINS
|
||||
)
|
||||
|
||||
self.sphinx_discovery = SphinxDiscovery(
|
||||
url_manager=self.url_manager
|
||||
)
|
||||
|
||||
self.navigation_parser = NavigationParser(
|
||||
url_manager=self.url_manager
|
||||
)
|
||||
|
||||
self.navigation_trees = []
|
||||
|
||||
self.cleaner = HTMLCleaner(
|
||||
REMOVE_SELECTORS
|
||||
)
|
||||
|
||||
self.extractor = HTMLExtractor(
|
||||
CONTENT_SELECTORS
|
||||
)
|
||||
|
||||
self.asset_downloader = AssetDownloader(
|
||||
output_dir=str(Path(OUTPUT_DIR) / "assets"),
|
||||
http_client=self.http_client.client,
|
||||
)
|
||||
|
||||
self.converter = MarkdownConverter()
|
||||
|
||||
self.writer = FileWriter()
|
||||
|
||||
self.db = MetadataDB()
|
||||
|
||||
self.path_mapper = PathMapper(
|
||||
Path(OUTPUT_DIR) / "markdown"
|
||||
)
|
||||
|
||||
self.processed_this_run = 0
|
||||
|
||||
# Single lock serializing DB write blocks across
|
||||
# concurrent workers. SQLite + Python's sqlite3 module
|
||||
# already mutex on the connection, but a transaction()
|
||||
# context spans many statements — without this lock,
|
||||
# two workers could interleave statements mid-tx.
|
||||
self.db_lock = asyncio.Lock()
|
||||
|
||||
# In-flight worker count for clean shutdown: a worker
|
||||
# that sees an empty queue must wait until all peers
|
||||
# finish (their process_page may enqueue new URLs).
|
||||
self._in_flight = 0
|
||||
self._stop = False
|
||||
|
||||
# Track domains we've already navigation-parsed.
|
||||
# Sidebars are site-wide on Sphinx/Javadoc, so the same
|
||||
# tree is rediscovered on every page — running the
|
||||
# heavy parser repeatedly wastes CPU. Any per-section
|
||||
# URLs we miss here will still be picked up by
|
||||
# extract_links (it iterates all <a href>).
|
||||
self._nav_parsed_domains = set()
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize crawler resources.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Initializing crawler..."
|
||||
)
|
||||
|
||||
self.db.reset_stuck_processing()
|
||||
|
||||
await self.client.start()
|
||||
|
||||
for url in START_URLS:
|
||||
normalized = self.url_manager.normalize(url)
|
||||
|
||||
if normalized and self.url_manager.is_allowed(normalized):
|
||||
self.db.enqueue_url(
|
||||
normalized,
|
||||
parent_url=None,
|
||||
depth=0,
|
||||
priority=100
|
||||
)
|
||||
|
||||
for url in START_URLS:
|
||||
discovered = await self.sphinx_discovery.discover_from_base(url)
|
||||
|
||||
for discovered_url in discovered:
|
||||
if not self.url_manager.should_enqueue(discovered_url):
|
||||
continue
|
||||
|
||||
if self.db.is_done(discovered_url):
|
||||
continue
|
||||
|
||||
if self.db.is_known(discovered_url):
|
||||
continue
|
||||
|
||||
self.db.enqueue_url(
|
||||
discovered_url,
|
||||
parent_url=url,
|
||||
depth=1,
|
||||
priority=50
|
||||
)
|
||||
|
||||
self.db.save_discovery_source(
|
||||
url=discovered_url,
|
||||
discovery_type="sphinx_base_discovery",
|
||||
source_url=url
|
||||
)
|
||||
logger.info(f"Initial queue stats: {self.db.queue_stats()}")
|
||||
|
||||
async def shutdown(self):
|
||||
"""
|
||||
Cleanup resources.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Shutting down crawler..."
|
||||
)
|
||||
|
||||
self.export_summary()
|
||||
|
||||
await self.client.stop()
|
||||
|
||||
await self.http_client.aclose()
|
||||
|
||||
self.db.close()
|
||||
|
||||
logger.info(
|
||||
"Crawler shutdown complete."
|
||||
)
|
||||
|
||||
async def crawl(self):
|
||||
"""
|
||||
Main crawl entry point. Spawns CONCURRENCY workers that
|
||||
share the single browser context + httpx client and
|
||||
serialize through self.db_lock for DB writes.
|
||||
"""
|
||||
|
||||
await self.initialize()
|
||||
|
||||
try:
|
||||
workers = [
|
||||
asyncio.create_task(self._worker(i))
|
||||
for i in range(CONCURRENCY)
|
||||
]
|
||||
await asyncio.gather(*workers)
|
||||
finally:
|
||||
await self.shutdown()
|
||||
|
||||
async def _claim_next_url(self):
|
||||
"""
|
||||
Atomically pick the next pending URL and mark it
|
||||
processing. Returns None when the queue is empty.
|
||||
"""
|
||||
async with self.db_lock:
|
||||
item = self.db.get_next_pending_url()
|
||||
if not item:
|
||||
return None
|
||||
url = item["url"]
|
||||
if self.db.is_done(url):
|
||||
# Stale row — skip without claiming.
|
||||
return "skip"
|
||||
self.db.mark_processing(url)
|
||||
self._in_flight += 1
|
||||
return item
|
||||
|
||||
async def _worker(self, worker_id: int):
|
||||
"""
|
||||
One concurrent worker. Loops until the queue stays
|
||||
empty *and* no peer worker is in-flight (peers can
|
||||
enqueue new URLs as they discover links).
|
||||
"""
|
||||
while not self._stop:
|
||||
if self.processed_this_run >= MAX_PAGES_PER_RUN:
|
||||
return
|
||||
|
||||
item = await self._claim_next_url()
|
||||
|
||||
if item == "skip":
|
||||
continue
|
||||
|
||||
if item is None:
|
||||
# No work available — but a peer might enqueue
|
||||
# more. Exit only if everyone else is idle too.
|
||||
if self._in_flight == 0:
|
||||
return
|
||||
await asyncio.sleep(0.3)
|
||||
continue
|
||||
|
||||
url = item["url"]
|
||||
depth = item["depth"]
|
||||
|
||||
try:
|
||||
result = await self.process_page(
|
||||
url=url,
|
||||
depth=depth,
|
||||
)
|
||||
|
||||
async with self.db_lock:
|
||||
self.db.mark_done(url)
|
||||
if result and result.url != url:
|
||||
self.db.mark_done(result.url)
|
||||
self.processed_this_run += 1
|
||||
n = self.processed_this_run
|
||||
|
||||
if n % 20 == 0:
|
||||
logger.info(
|
||||
f"Progress: {n} pages this run. "
|
||||
f"Queue: {self.db.queue_stats()}"
|
||||
)
|
||||
if n % 100 == 0:
|
||||
self.export_summary()
|
||||
logger.info(
|
||||
"Intermediate summary exported."
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
except _PermanentFetchFailure as e:
|
||||
# Skip the retry ramp — re-fetching can't
|
||||
# succeed.
|
||||
logger.warning(f"Permanent failure on {url}: {e}")
|
||||
async with self.db_lock:
|
||||
self.db.mark_permanently_failed(url, str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed processing {url}: {e}"
|
||||
)
|
||||
async with self.db_lock:
|
||||
self.db.mark_failed(
|
||||
url,
|
||||
str(e),
|
||||
max_attempts=MAX_ATTEMPTS,
|
||||
)
|
||||
|
||||
finally:
|
||||
async with self.db_lock:
|
||||
self._in_flight -= 1
|
||||
async def _fetch_and_extract(self, url: str):
|
||||
"""
|
||||
Try a cheap HTTP fetch first. If a configured content
|
||||
selector matches we're done. Otherwise fall back to a
|
||||
full Playwright render (handles JS-only pages).
|
||||
|
||||
Returns (response_dict, extraction_dict) or (None, None)
|
||||
when neither path produces usable content.
|
||||
"""
|
||||
|
||||
# Conditional GET: ask the server whether the page
|
||||
# has changed since we last cached it. A 304 lets us
|
||||
# skip all downstream work.
|
||||
etag, last_modified = self.db.get_cached_headers(url)
|
||||
|
||||
http_response = await self.http_client.fetch_page(
|
||||
url,
|
||||
etag=etag,
|
||||
last_modified=last_modified,
|
||||
)
|
||||
|
||||
if http_response is not None and http_response.get("is_binary"):
|
||||
return http_response, None
|
||||
|
||||
if http_response is not None and http_response.get("not_modified"):
|
||||
# Signal "nothing changed" to process_page.
|
||||
return http_response, None
|
||||
|
||||
# Hard 4xx (404, 410, 451, ...): there is nothing
|
||||
# Playwright can do that httpx cannot. Surface this so
|
||||
# the worker can mark the URL permanently failed and
|
||||
# stop wasting retries.
|
||||
if (
|
||||
http_response is not None
|
||||
and http_response.get("status_code") is not None
|
||||
and 400 <= http_response["status_code"] < 500
|
||||
):
|
||||
return http_response, None
|
||||
|
||||
if http_response is not None and http_response.get("ok"):
|
||||
# Parse the HTML once here. The cleaner mutates
|
||||
# the soup in place; the extractor uses it; assets
|
||||
# later use the matched node. No re-parses.
|
||||
cleaning_soup = BeautifulSoup(
|
||||
http_response["html"], "lxml"
|
||||
)
|
||||
self.cleaner.clean(cleaning_soup)
|
||||
extraction = self.extractor.extract_with_status(
|
||||
cleaning_soup
|
||||
)
|
||||
|
||||
if extraction["matched"]:
|
||||
logger.info(f"HTTP fast-path success: {url}")
|
||||
return http_response, extraction
|
||||
|
||||
logger.info(
|
||||
f"HTTP fetch ok but no content selector matched "
|
||||
f"— falling back to Playwright: {url}"
|
||||
)
|
||||
|
||||
# Fallback: full browser render
|
||||
pw_response = await self.client.fetch_page(url)
|
||||
|
||||
if pw_response.get("is_binary"):
|
||||
return pw_response, None
|
||||
|
||||
cleaning_soup = BeautifulSoup(pw_response["html"], "lxml")
|
||||
self.cleaner.clean(cleaning_soup)
|
||||
extraction = self.extractor.extract_with_status(
|
||||
cleaning_soup
|
||||
)
|
||||
|
||||
return pw_response, extraction
|
||||
|
||||
async def process_page(self, url: str, depth: int):
|
||||
"""
|
||||
Process single page.
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
f"Crawling: {url}"
|
||||
)
|
||||
if self.url_manager.is_binary_url(url):
|
||||
logger.info(f"Skipping binary URL as page: {url}")
|
||||
return None
|
||||
|
||||
response, extraction = await self._fetch_and_extract(url)
|
||||
|
||||
if response is None:
|
||||
return None
|
||||
|
||||
if response.get("is_binary"):
|
||||
logger.info(f"Skipping binary response: {url}")
|
||||
return None
|
||||
|
||||
# 304 Not Modified: skip the entire pipeline. Worker
|
||||
# will mark_done as usual.
|
||||
if response.get("not_modified"):
|
||||
logger.info(f"Not modified, skipping: {url}")
|
||||
return CrawlResult(
|
||||
status_code=304,
|
||||
url=url,
|
||||
title="",
|
||||
markdown_path="",
|
||||
links=[],
|
||||
success=True,
|
||||
)
|
||||
|
||||
# _fetch_and_extract returns extraction=None with a
|
||||
# 4xx response when the URL is a permanent failure
|
||||
# (404 etc.). Raise a typed error so the worker maps
|
||||
# it to mark_permanently_failed instead of retrying.
|
||||
status_code = response.get("status_code")
|
||||
if extraction is None and status_code and 400 <= status_code < 500:
|
||||
raise _PermanentFetchFailure(
|
||||
f"HTTP {status_code} on {url}"
|
||||
)
|
||||
|
||||
final_url = response.get("url", url)
|
||||
|
||||
html = response["html"]
|
||||
|
||||
title = response["title"]
|
||||
|
||||
# The extracted node lives inside the cleaning soup
|
||||
# already produced in _fetch_and_extract. Use it
|
||||
# directly to avoid one BS4 re-parse per page.
|
||||
extracted_node = extraction["node"]
|
||||
|
||||
# Resolve output path first
|
||||
output_path = self.path_mapper.url_to_path(
|
||||
final_url
|
||||
)
|
||||
|
||||
# Download assets — pass the already-parsed node
|
||||
# rather than its serialized HTML string.
|
||||
downloaded_assets = await self.asset_downloader.download_assets_from_html(
|
||||
base_url=final_url,
|
||||
html=extracted_node,
|
||||
)
|
||||
|
||||
async with self.db_lock:
|
||||
with self.db.transaction():
|
||||
for asset in downloaded_assets:
|
||||
self.db.save_asset(
|
||||
page_url=final_url,
|
||||
asset_url=asset["url"],
|
||||
local_path=asset["local_path"]
|
||||
)
|
||||
# Rewrite asset links in place on the same parsed
|
||||
# node — no re-parse needed.
|
||||
self.asset_downloader.rewrite_asset_links(
|
||||
html=extracted_node,
|
||||
base_url=final_url,
|
||||
current_output_path=output_path,
|
||||
)
|
||||
|
||||
# Convert markdown
|
||||
markdown = self.converter.html_to_markdown(
|
||||
str(extracted_node),
|
||||
title=title,
|
||||
source_url=final_url,
|
||||
media_dir=Path(OUTPUT_DIR) / "assets" / "pandoc",
|
||||
)
|
||||
|
||||
self.writer.write_text(
|
||||
output_path,
|
||||
markdown
|
||||
)
|
||||
|
||||
# Parse the ORIGINAL html ONCE for all three
|
||||
# downstream consumers. Previously each one re-parsed
|
||||
# the same string independently.
|
||||
original_soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
discovered_links = self.extract_links(
|
||||
final_url,
|
||||
original_soup,
|
||||
)
|
||||
|
||||
sphinx_links = await self.sphinx_discovery.discover_from_rendered_html(
|
||||
base_url=final_url,
|
||||
html=original_soup,
|
||||
)
|
||||
|
||||
# Only parse navigation the first time we visit a
|
||||
# given domain. The sidebar tree is site-wide and
|
||||
# repeating the parse on every page is the single
|
||||
# most expensive bit of per-page CPU we still have.
|
||||
nav_domain = self.url_manager.extract_domain(final_url)
|
||||
if nav_domain in self._nav_parsed_domains:
|
||||
navigation_result = {"urls": [], "tree": []}
|
||||
else:
|
||||
navigation_result = self.navigation_parser.parse_navigation(
|
||||
base_url=final_url,
|
||||
html=original_soup,
|
||||
)
|
||||
self._nav_parsed_domains.add(nav_domain)
|
||||
|
||||
self.navigation_trees.extend(
|
||||
navigation_result["tree"]
|
||||
)
|
||||
|
||||
for nav_url in navigation_result["urls"]:
|
||||
if nav_url not in discovered_links:
|
||||
discovered_links.append(nav_url)
|
||||
|
||||
logger.info(
|
||||
f"SPHINX_LINKS: {len(sphinx_links)} from {final_url}"
|
||||
)
|
||||
|
||||
for sphinx_link in sphinx_links:
|
||||
if sphinx_link not in discovered_links:
|
||||
discovered_links.append(sphinx_link)
|
||||
|
||||
# Batch every per-link write + the page row into one
|
||||
# transaction per page — turns hundreds of fsyncs into
|
||||
# one, and one acquisition of db_lock per worker turn.
|
||||
async with self.db_lock:
|
||||
with self.db.transaction():
|
||||
for link in discovered_links:
|
||||
|
||||
logger.debug(f"DISCOVERED: {link}")
|
||||
|
||||
self.db.save_page_link(
|
||||
source_url=final_url,
|
||||
target_url=link,
|
||||
link_type="sphinx_or_html_internal"
|
||||
)
|
||||
|
||||
if not self.url_manager.should_enqueue(link):
|
||||
continue
|
||||
|
||||
if self.db.is_done(link):
|
||||
continue
|
||||
|
||||
if self.db.is_known(link):
|
||||
continue
|
||||
|
||||
logger.debug(f"ENQUEUED: {link}")
|
||||
|
||||
self.db.enqueue_url(
|
||||
url=link,
|
||||
parent_url=final_url,
|
||||
depth=depth + 1,
|
||||
priority=0
|
||||
)
|
||||
|
||||
self.db.save_discovery_source(
|
||||
url=link,
|
||||
discovery_type="page_internal_link",
|
||||
source_url=final_url
|
||||
)
|
||||
|
||||
self.db.save_page(
|
||||
url=final_url,
|
||||
title=title,
|
||||
markdown_path=str(output_path),
|
||||
status="success",
|
||||
status_code=response.get(
|
||||
"status_code",
|
||||
200
|
||||
),
|
||||
markdown_length=len(markdown),
|
||||
word_count=len(
|
||||
markdown.split()
|
||||
),
|
||||
metadata={
|
||||
"depth": depth,
|
||||
"links_found": len(discovered_links),
|
||||
"assets_found": len(downloaded_assets)
|
||||
},
|
||||
etag=response.get("etag"),
|
||||
last_modified=response.get("last_modified"),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Saved markdown: "
|
||||
f"{output_path}"
|
||||
)
|
||||
|
||||
return CrawlResult(
|
||||
status_code=response.get("status_code"),
|
||||
url=final_url,
|
||||
title=title,
|
||||
markdown_path=str(output_path),
|
||||
links=discovered_links,
|
||||
success=True
|
||||
)
|
||||
|
||||
def extract_links(
|
||||
self,
|
||||
base_url,
|
||||
html
|
||||
):
|
||||
"""
|
||||
Extract crawlable links. Accepts an HTML string or a
|
||||
parsed BS4 soup/Tag.
|
||||
"""
|
||||
|
||||
if hasattr(html, "select") and hasattr(html, "find_all"):
|
||||
soup = html
|
||||
else:
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
discovered = set()
|
||||
|
||||
for a in soup.find_all(
|
||||
"a",
|
||||
href=True
|
||||
):
|
||||
|
||||
href = a.get("href")
|
||||
|
||||
resolved = self.url_manager.resolve(
|
||||
base_url,
|
||||
href
|
||||
)
|
||||
|
||||
if not resolved:
|
||||
continue
|
||||
|
||||
if not self.url_manager.is_allowed(
|
||||
resolved
|
||||
):
|
||||
continue
|
||||
|
||||
discovered.add(
|
||||
resolved
|
||||
)
|
||||
selectors = [
|
||||
"a.reference.internal",
|
||||
".toctree-wrapper a",
|
||||
".wy-menu a",
|
||||
".sphinxsidebar a",
|
||||
".globaltoc a",
|
||||
".localtoc a",
|
||||
"link[rel='next']",
|
||||
"link[rel='prev']",
|
||||
"link[rel='up']"
|
||||
]
|
||||
|
||||
for selector in selectors:
|
||||
for node in soup.select(selector):
|
||||
href = node.get("href")
|
||||
|
||||
resolved = self.url_manager.resolve(
|
||||
base_url,
|
||||
href
|
||||
)
|
||||
|
||||
if not resolved:
|
||||
continue
|
||||
|
||||
if not self.url_manager.is_allowed(resolved):
|
||||
continue
|
||||
|
||||
discovered.add(resolved)
|
||||
|
||||
return list(discovered)
|
||||
|
||||
def export_summary(self):
|
||||
"""
|
||||
Export crawl summary.
|
||||
"""
|
||||
|
||||
summary_path = (
|
||||
Path(OUTPUT_DIR)
|
||||
/ "crawl_summary.json"
|
||||
)
|
||||
|
||||
data = {
|
||||
"processed_this_run": self.processed_this_run,
|
||||
"queue_stats": self.db.queue_stats()
|
||||
}
|
||||
|
||||
self.writer.write_json(
|
||||
summary_path,
|
||||
data
|
||||
)
|
||||
|
||||
graph_path = (
|
||||
Path(OUTPUT_DIR)
|
||||
/ "site_graph.json"
|
||||
)
|
||||
|
||||
self.db.export_site_graph(
|
||||
graph_path
|
||||
)
|
||||
|
||||
navigation_tree = self.navigation_parser.dedupe_tree(
|
||||
self.navigation_trees
|
||||
)
|
||||
|
||||
navigation_path = (
|
||||
Path(OUTPUT_DIR)
|
||||
/ "navigation_tree.json"
|
||||
)
|
||||
|
||||
self.navigation_parser.export_tree_json(
|
||||
navigation_tree,
|
||||
navigation_path
|
||||
)
|
||||
|
||||
summary_md = self.navigation_parser.generate_summary_markdown(
|
||||
navigation_tree
|
||||
)
|
||||
|
||||
summary_md_path = (
|
||||
Path(OUTPUT_DIR)
|
||||
/ "SUMMARY.md"
|
||||
)
|
||||
|
||||
self.writer.write_text(
|
||||
summary_md_path,
|
||||
summary_md
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Summary exported: "
|
||||
f"{summary_path}"
|
||||
)
|
||||
|
||||
async def recycle_browser(self):
|
||||
"""
|
||||
Restart Playwright browser/context periodically
|
||||
to avoid long-running memory/socket leaks.
|
||||
"""
|
||||
|
||||
logger.info("Recycling browser context...")
|
||||
|
||||
await self.client.stop()
|
||||
await asyncio.sleep(2)
|
||||
await self.client.start()
|
||||
|
||||
logger.info("Browser context recycled.")
|
||||
|
||||
async def async_main():
|
||||
crawler = DocumentationCrawler()
|
||||
await crawler.crawl()
|
||||
|
||||
|
||||
def main():
|
||||
asyncio.run(
|
||||
async_main()
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
models/__init__.py
Normal file
0
models/__init__.py
Normal file
228
models/crawl_result.py
Normal file
228
models/crawl_result.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _utcnow_naive() -> datetime:
|
||||
"""
|
||||
Replacement for the deprecated datetime.utcnow().
|
||||
|
||||
Returns a naive UTC datetime (no tzinfo) to preserve
|
||||
the historical wire format.
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawlResult:
|
||||
"""
|
||||
Result object for a crawled page.
|
||||
"""
|
||||
|
||||
# Core
|
||||
url: str
|
||||
title: str
|
||||
|
||||
# Content
|
||||
raw_html: Optional[str] = None
|
||||
markdown: Optional[str] = None
|
||||
|
||||
markdown_path: Optional[str] = None
|
||||
|
||||
# Status
|
||||
status_code: Optional[int] = None
|
||||
success: bool = True
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# Links (ONLY ONE SOURCE OF TRUTH)
|
||||
links: List[str] = field(default_factory=list)
|
||||
|
||||
# Timing
|
||||
crawled_at: datetime = field(default_factory=_utcnow_naive)
|
||||
|
||||
# Assets
|
||||
assets: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Navigation
|
||||
navigation_tree: Optional[list] = None
|
||||
|
||||
# Stats
|
||||
content_length: int = 0
|
||||
markdown_length: int = 0
|
||||
word_count: int = 0
|
||||
|
||||
# Metadata
|
||||
tags: List[str] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def compute_statistics(self):
|
||||
"""
|
||||
Compute markdown statistics.
|
||||
"""
|
||||
|
||||
if not self.markdown:
|
||||
return
|
||||
|
||||
self.markdown_length = len(
|
||||
self.markdown
|
||||
)
|
||||
|
||||
self.word_count = len(
|
||||
self.markdown.split()
|
||||
)
|
||||
|
||||
self.content_length = len(
|
||||
self.markdown or ""
|
||||
)
|
||||
|
||||
def add_asset(
|
||||
self,
|
||||
asset_url: str,
|
||||
local_path: str
|
||||
):
|
||||
"""
|
||||
Add downloaded asset.
|
||||
"""
|
||||
|
||||
self.assets.append({
|
||||
"url": asset_url,
|
||||
"local_path": local_path
|
||||
})
|
||||
|
||||
def add_discovered_link(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Add discovered URL.
|
||||
"""
|
||||
|
||||
if url not in self.links:
|
||||
self.links.append(url)
|
||||
|
||||
def mark_failed(
|
||||
self,
|
||||
error_message: str
|
||||
):
|
||||
"""
|
||||
Mark crawl as failed.
|
||||
"""
|
||||
|
||||
self.success = False
|
||||
|
||||
self.error_message = error_message
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
Convert object to serializable dict.
|
||||
"""
|
||||
|
||||
return {
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"markdown_path": self.markdown_path,
|
||||
"success": self.success,
|
||||
"error_message": self.error_message,
|
||||
"status_code": self.status_code,
|
||||
"crawled_at": self.crawled_at.isoformat(),
|
||||
"assets": self.assets,
|
||||
"links": self.links,
|
||||
"content_length": self.content_length,
|
||||
"markdown_length": self.markdown_length,
|
||||
"word_count": self.word_count,
|
||||
"tags": self.tags,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
data: dict
|
||||
):
|
||||
"""
|
||||
Restore CrawlResult from dict.
|
||||
"""
|
||||
|
||||
obj = cls(
|
||||
url=data.get("url", ""),
|
||||
title=data.get("title", "")
|
||||
)
|
||||
|
||||
obj.markdown_path = data.get(
|
||||
"markdown_path"
|
||||
)
|
||||
|
||||
obj.success = data.get(
|
||||
"success",
|
||||
True
|
||||
)
|
||||
|
||||
obj.error_message = data.get(
|
||||
"error_message"
|
||||
)
|
||||
|
||||
obj.status_code = data.get(
|
||||
"status_code"
|
||||
)
|
||||
|
||||
obj.assets = data.get(
|
||||
"assets",
|
||||
[]
|
||||
)
|
||||
|
||||
obj.links = data.get(
|
||||
"links",
|
||||
[]
|
||||
)
|
||||
|
||||
obj.content_length = data.get(
|
||||
"content_length",
|
||||
0
|
||||
)
|
||||
|
||||
obj.markdown_length = data.get(
|
||||
"markdown_length",
|
||||
0
|
||||
)
|
||||
|
||||
obj.word_count = data.get(
|
||||
"word_count",
|
||||
0
|
||||
)
|
||||
|
||||
obj.tags = data.get(
|
||||
"tags",
|
||||
[]
|
||||
)
|
||||
|
||||
obj.metadata = data.get(
|
||||
"metadata",
|
||||
{}
|
||||
)
|
||||
|
||||
crawled_at = data.get(
|
||||
"crawled_at"
|
||||
)
|
||||
|
||||
if crawled_at:
|
||||
|
||||
obj.crawled_at = datetime.fromisoformat(
|
||||
crawled_at
|
||||
)
|
||||
|
||||
return obj
|
||||
|
||||
def summary(self):
|
||||
"""
|
||||
Human-readable summary.
|
||||
"""
|
||||
|
||||
return (
|
||||
f"CrawlResult("
|
||||
f"url={self.url}, "
|
||||
f"success={self.success}, "
|
||||
f"markdown_length={self.markdown_length}, "
|
||||
f"assets={len(self.assets)}, "
|
||||
f"links={len(self.links)}"
|
||||
f")"
|
||||
)
|
||||
264
models/page.py
Normal file
264
models/page.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _utcnow_naive() -> datetime:
|
||||
"""
|
||||
Replacement for the deprecated datetime.utcnow().
|
||||
|
||||
Returns a naive UTC datetime (no tzinfo) to preserve
|
||||
the historical wire format.
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
"""
|
||||
Raw rendered page object.
|
||||
|
||||
Represents a fetched documentation page
|
||||
before processing pipeline.
|
||||
"""
|
||||
|
||||
# Core metadata
|
||||
url: str
|
||||
title: str
|
||||
|
||||
# Raw content
|
||||
html: str
|
||||
|
||||
# HTTP metadata
|
||||
status_code: Optional[int] = None
|
||||
|
||||
headers: Dict[str, str] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
# Render metadata
|
||||
rendered: bool = True
|
||||
|
||||
# Timing
|
||||
fetched_at: datetime = field(
|
||||
default_factory=_utcnow_naive
|
||||
)
|
||||
|
||||
# Content metadata
|
||||
content_type: Optional[str] = None
|
||||
|
||||
encoding: Optional[str] = "utf-8"
|
||||
|
||||
# Link extraction
|
||||
discovered_links: List[str] = field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
# Asset extraction
|
||||
assets: List[str] = field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
# Optional processed outputs
|
||||
markdown: Optional[str] = None
|
||||
|
||||
local_path: Optional[str] = None
|
||||
|
||||
# Crawl state
|
||||
depth: int = 0
|
||||
|
||||
parent_url: Optional[str] = None
|
||||
|
||||
# Error handling
|
||||
success: bool = True
|
||||
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# Extra metadata
|
||||
metadata: Dict = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
def add_link(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Add discovered URL.
|
||||
"""
|
||||
|
||||
if url not in self.discovered_links:
|
||||
|
||||
self.discovered_links.append(url)
|
||||
|
||||
def add_asset(
|
||||
self,
|
||||
asset_url: str
|
||||
):
|
||||
"""
|
||||
Add asset URL.
|
||||
"""
|
||||
|
||||
if asset_url not in self.assets:
|
||||
|
||||
self.assets.append(asset_url)
|
||||
|
||||
def mark_failed(
|
||||
self,
|
||||
message: str
|
||||
):
|
||||
"""
|
||||
Mark page fetch failed.
|
||||
"""
|
||||
|
||||
self.success = False
|
||||
|
||||
self.error_message = message
|
||||
|
||||
def html_length(self):
|
||||
"""
|
||||
Return HTML size.
|
||||
"""
|
||||
|
||||
return len(self.html or "")
|
||||
|
||||
def word_count(self):
|
||||
"""
|
||||
Approximate word count.
|
||||
"""
|
||||
|
||||
text = self.html or ""
|
||||
|
||||
return len(text.split())
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
Serialize object.
|
||||
"""
|
||||
|
||||
return {
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"status_code": self.status_code,
|
||||
"headers": self.headers,
|
||||
"rendered": self.rendered,
|
||||
"fetched_at": self.fetched_at.isoformat(),
|
||||
"content_type": self.content_type,
|
||||
"encoding": self.encoding,
|
||||
"discovered_links": self.discovered_links,
|
||||
"assets": self.assets,
|
||||
"markdown": self.markdown,
|
||||
"local_path": self.local_path,
|
||||
"depth": self.depth,
|
||||
"parent_url": self.parent_url,
|
||||
"success": self.success,
|
||||
"error_message": self.error_message,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
data: dict
|
||||
):
|
||||
"""
|
||||
Restore from serialized dict.
|
||||
"""
|
||||
|
||||
page = cls(
|
||||
url=data["url"],
|
||||
title=data.get("title", ""),
|
||||
html=data.get("html", "")
|
||||
)
|
||||
|
||||
page.status_code = data.get(
|
||||
"status_code"
|
||||
)
|
||||
|
||||
page.headers = data.get(
|
||||
"headers",
|
||||
{}
|
||||
)
|
||||
|
||||
page.rendered = data.get(
|
||||
"rendered",
|
||||
True
|
||||
)
|
||||
|
||||
page.content_type = data.get(
|
||||
"content_type"
|
||||
)
|
||||
|
||||
page.encoding = data.get(
|
||||
"encoding",
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
page.discovered_links = data.get(
|
||||
"discovered_links",
|
||||
[]
|
||||
)
|
||||
|
||||
page.assets = data.get(
|
||||
"assets",
|
||||
[]
|
||||
)
|
||||
|
||||
page.markdown = data.get(
|
||||
"markdown"
|
||||
)
|
||||
|
||||
page.local_path = data.get(
|
||||
"local_path"
|
||||
)
|
||||
|
||||
page.depth = data.get(
|
||||
"depth",
|
||||
0
|
||||
)
|
||||
|
||||
page.parent_url = data.get(
|
||||
"parent_url"
|
||||
)
|
||||
|
||||
page.success = data.get(
|
||||
"success",
|
||||
True
|
||||
)
|
||||
|
||||
page.error_message = data.get(
|
||||
"error_message"
|
||||
)
|
||||
|
||||
page.metadata = data.get(
|
||||
"metadata",
|
||||
{}
|
||||
)
|
||||
|
||||
fetched_at = data.get(
|
||||
"fetched_at"
|
||||
)
|
||||
|
||||
if fetched_at:
|
||||
|
||||
page.fetched_at = datetime.fromisoformat(
|
||||
fetched_at
|
||||
)
|
||||
|
||||
return page
|
||||
|
||||
def summary(self):
|
||||
"""
|
||||
Human-readable summary.
|
||||
"""
|
||||
|
||||
return (
|
||||
f"Page("
|
||||
f"url={self.url}, "
|
||||
f"title={self.title}, "
|
||||
f"status={self.status_code}, "
|
||||
f"links={len(self.discovered_links)}, "
|
||||
f"assets={len(self.assets)}, "
|
||||
f"success={self.success}"
|
||||
f")"
|
||||
)
|
||||
64
pyproject.toml
Normal file
64
pyproject.toml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "intra-mart-doc-crawler"
|
||||
version = "1.0.0"
|
||||
description = "Production-grade documentation crawler and markdown exporter"
|
||||
authors = [
|
||||
{ name = "Iric Do" }
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
dependencies = [
|
||||
"playwright>=1.53.0",
|
||||
"beautifulsoup4>=4.12.3",
|
||||
"lxml>=5.2.2",
|
||||
"httpx>=0.28.1",
|
||||
"aiofiles>=24.1.0",
|
||||
"loguru>=0.7.2",
|
||||
"tenacity>=9.0.0",
|
||||
"markdownify>=0.13.1",
|
||||
"PyYAML>=6.0.2"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
dev = [
|
||||
"black>=24.4.2",
|
||||
"ruff>=0.5.0",
|
||||
"pytest>=8.2.2",
|
||||
"pytest-asyncio>=0.23.7",
|
||||
"mypy>=1.10.1"
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = [
|
||||
"crawler*",
|
||||
"converter*",
|
||||
"extractor*",
|
||||
"models*",
|
||||
"storage*",
|
||||
"utils*"
|
||||
]
|
||||
|
||||
exclude = [
|
||||
"output*",
|
||||
"data*",
|
||||
"logs*"
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 79
|
||||
target-version = "py311"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
playwright
|
||||
beautifulsoup4
|
||||
lxml
|
||||
markdownify
|
||||
pypandoc
|
||||
loguru
|
||||
pyyaml
|
||||
httpx
|
||||
aiofiles
|
||||
tenacity
|
||||
typer
|
||||
rich
|
||||
0
storage/__init__.py
Normal file
0
storage/__init__.py
Normal file
414
storage/file_writer.py
Normal file
414
storage/file_writer.py
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class FileWriter:
|
||||
"""
|
||||
Production-ready file writer.
|
||||
|
||||
Supports:
|
||||
- text
|
||||
- json
|
||||
- binary
|
||||
- async write
|
||||
- atomic write
|
||||
- safe overwrite
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
encoding="utf-8"
|
||||
):
|
||||
self.encoding = encoding
|
||||
|
||||
def ensure_parent(
|
||||
self,
|
||||
path: Path
|
||||
):
|
||||
"""
|
||||
Ensure parent directory exists.
|
||||
"""
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
def write_text(
|
||||
self,
|
||||
path: Path,
|
||||
content: str,
|
||||
overwrite=True
|
||||
):
|
||||
"""
|
||||
Write text file safely.
|
||||
"""
|
||||
|
||||
self.ensure_parent(path)
|
||||
|
||||
if path.exists() and not overwrite:
|
||||
|
||||
logger.warning(
|
||||
f"Skip existing file: {path}"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
# Create the temp file in the SAME directory as the
|
||||
# destination so that shutil.move can do a same-FS
|
||||
# atomic rename. mkstemp on /tmp can land on a
|
||||
# different mount point, forcing a non-atomic
|
||||
# copy+delete fallback. Also: mkstemp returns a raw
|
||||
# fd that must be closed -- the old code leaked one
|
||||
# fd per write_text call.
|
||||
temp_fd, temp_path = tempfile.mkstemp(
|
||||
prefix=".tmp_",
|
||||
suffix=path.suffix,
|
||||
dir=str(path.parent)
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
with os.fdopen(
|
||||
temp_fd,
|
||||
"w",
|
||||
encoding=self.encoding
|
||||
) as f:
|
||||
|
||||
f.write(content)
|
||||
|
||||
shutil.move(
|
||||
temp_path,
|
||||
path
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Saved text file: {path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Failed writing text "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
# Clean up the temp file if move never happened.
|
||||
try:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise
|
||||
|
||||
async def write_text_async(
|
||||
self,
|
||||
path: Path,
|
||||
content: str
|
||||
):
|
||||
"""
|
||||
Async text writer.
|
||||
"""
|
||||
|
||||
self.ensure_parent(path)
|
||||
|
||||
try:
|
||||
|
||||
async with aiofiles.open(
|
||||
path,
|
||||
"w",
|
||||
encoding=self.encoding
|
||||
) as f:
|
||||
|
||||
await f.write(content)
|
||||
|
||||
logger.info(
|
||||
f"Saved async text: {path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Async write failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
def write_json(
|
||||
self,
|
||||
path: Path,
|
||||
data,
|
||||
indent=2
|
||||
):
|
||||
"""
|
||||
Write JSON file.
|
||||
"""
|
||||
|
||||
self.ensure_parent(path)
|
||||
|
||||
try:
|
||||
|
||||
with open(
|
||||
path,
|
||||
"w",
|
||||
encoding=self.encoding
|
||||
) as f:
|
||||
|
||||
json.dump(
|
||||
data,
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=indent
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Saved JSON: {path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"JSON write failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
async def write_json_async(
|
||||
self,
|
||||
path: Path,
|
||||
data,
|
||||
indent=2
|
||||
):
|
||||
"""
|
||||
Async JSON writer.
|
||||
"""
|
||||
|
||||
self.ensure_parent(path)
|
||||
|
||||
try:
|
||||
|
||||
content = json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
indent=indent
|
||||
)
|
||||
|
||||
async with aiofiles.open(
|
||||
path,
|
||||
"w",
|
||||
encoding=self.encoding
|
||||
) as f:
|
||||
|
||||
await f.write(content)
|
||||
|
||||
logger.info(
|
||||
f"Saved async JSON: {path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Async JSON write failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
def write_binary(
|
||||
self,
|
||||
path: Path,
|
||||
content: bytes
|
||||
):
|
||||
"""
|
||||
Write binary file.
|
||||
"""
|
||||
|
||||
self.ensure_parent(path)
|
||||
|
||||
try:
|
||||
|
||||
with open(
|
||||
path,
|
||||
"wb"
|
||||
) as f:
|
||||
|
||||
f.write(content)
|
||||
|
||||
logger.info(
|
||||
f"Saved binary: {path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Binary write failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
async def append_text(
|
||||
self,
|
||||
path: Path,
|
||||
content: str
|
||||
):
|
||||
"""
|
||||
Append text to file.
|
||||
"""
|
||||
|
||||
self.ensure_parent(path)
|
||||
|
||||
try:
|
||||
|
||||
async with aiofiles.open(
|
||||
path,
|
||||
"a",
|
||||
encoding=self.encoding
|
||||
) as f:
|
||||
|
||||
await f.write(content)
|
||||
|
||||
logger.info(
|
||||
f"Appended text: {path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Append failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
def read_text(
|
||||
self,
|
||||
path: Path
|
||||
):
|
||||
"""
|
||||
Read text file.
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
with open(
|
||||
path,
|
||||
"r",
|
||||
encoding=self.encoding
|
||||
) as f:
|
||||
|
||||
return f.read()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Read failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
def exists(
|
||||
self,
|
||||
path: Path
|
||||
):
|
||||
"""
|
||||
Check file existence.
|
||||
"""
|
||||
|
||||
return path.exists()
|
||||
|
||||
def delete(
|
||||
self,
|
||||
path: Path
|
||||
):
|
||||
"""
|
||||
Delete file safely.
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
if path.exists():
|
||||
|
||||
path.unlink()
|
||||
|
||||
logger.info(
|
||||
f"Deleted file: {path}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.exception(
|
||||
f"Delete failed "
|
||||
f"{path}: {e}"
|
||||
)
|
||||
|
||||
def sanitize_filename(
|
||||
self,
|
||||
filename: str
|
||||
):
|
||||
"""
|
||||
Remove invalid filesystem chars.
|
||||
"""
|
||||
|
||||
invalid_chars = [
|
||||
"<",
|
||||
">",
|
||||
":",
|
||||
"\"",
|
||||
"/",
|
||||
"\\",
|
||||
"|",
|
||||
"?",
|
||||
"*"
|
||||
]
|
||||
|
||||
for char in invalid_chars:
|
||||
|
||||
filename = filename.replace(
|
||||
char,
|
||||
"_"
|
||||
)
|
||||
|
||||
return filename.strip()
|
||||
|
||||
def write_markdown_with_metadata(
|
||||
self,
|
||||
path: Path,
|
||||
markdown: str,
|
||||
metadata: dict = None
|
||||
):
|
||||
"""
|
||||
Write markdown with YAML frontmatter.
|
||||
"""
|
||||
|
||||
self.ensure_parent(path)
|
||||
|
||||
content = ""
|
||||
|
||||
if metadata:
|
||||
|
||||
content += "---\n"
|
||||
|
||||
for key, value in metadata.items():
|
||||
|
||||
content += f"{key}: {value}\n"
|
||||
|
||||
content += "---\n\n"
|
||||
|
||||
content += markdown
|
||||
|
||||
self.write_text(
|
||||
path,
|
||||
content
|
||||
)
|
||||
935
storage/metadata_db.py
Normal file
935
storage/metadata_db.py
Normal file
|
|
@ -0,0 +1,935 @@
|
|||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
"""
|
||||
Return a naive UTC ISO timestamp.
|
||||
|
||||
datetime.utcnow() is deprecated in Python 3.12+; this
|
||||
helper preserves the historical naive-UTC string format
|
||||
(no '+00:00' suffix) used throughout the schema so
|
||||
existing rows compare correctly.
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(
|
||||
tzinfo=None
|
||||
).isoformat()
|
||||
|
||||
|
||||
class MetadataDB:
|
||||
"""
|
||||
Metadata storage layer
|
||||
for crawler state tracking.
|
||||
|
||||
Stores:
|
||||
- crawled pages
|
||||
- crawl status
|
||||
- markdown outputs
|
||||
- assets
|
||||
- hashes
|
||||
- timestamps
|
||||
- incremental update info
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path="data/metadata.db"
|
||||
):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
self.db_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
self.conn = sqlite3.connect(
|
||||
self.db_path
|
||||
)
|
||||
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
|
||||
# depth > 0 means we're inside a batched transaction
|
||||
# and per-row commits should be suppressed
|
||||
self._tx_depth = 0
|
||||
|
||||
self.initialize()
|
||||
|
||||
def initialize(self):
|
||||
"""
|
||||
Create required tables.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA temp_store=MEMORY")
|
||||
cursor.execute("PRAGMA cache_size=-65536")
|
||||
|
||||
# Pages table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
url TEXT UNIQUE,
|
||||
title TEXT,
|
||||
|
||||
markdown_path TEXT,
|
||||
|
||||
status TEXT,
|
||||
status_code INTEGER,
|
||||
|
||||
content_hash TEXT,
|
||||
|
||||
word_count INTEGER,
|
||||
markdown_length INTEGER,
|
||||
|
||||
crawled_at TEXT,
|
||||
updated_at TEXT,
|
||||
|
||||
parent_url TEXT,
|
||||
depth INTEGER,
|
||||
|
||||
metadata_json TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Assets table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
page_url TEXT,
|
||||
|
||||
asset_url TEXT,
|
||||
local_path TEXT,
|
||||
|
||||
downloaded_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Site graph edges
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS page_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
source_url TEXT NOT NULL,
|
||||
target_url TEXT NOT NULL,
|
||||
|
||||
link_type TEXT DEFAULT 'internal',
|
||||
|
||||
discovered_at TEXT,
|
||||
|
||||
UNIQUE(source_url, target_url)
|
||||
)
|
||||
""")
|
||||
|
||||
# Discovery provenance
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS discovery_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
url TEXT NOT NULL,
|
||||
|
||||
discovery_type TEXT,
|
||||
source_url TEXT,
|
||||
|
||||
discovered_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Crawl queue state
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS crawl_queue (
|
||||
url TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
priority INTEGER DEFAULT 0,
|
||||
depth INTEGER DEFAULT 0,
|
||||
parent_url TEXT,
|
||||
discovered_at TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_error TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Failed pages
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS failures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
url TEXT,
|
||||
|
||||
error_message TEXT,
|
||||
|
||||
failed_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
# Add the indexes that drive the hot path. IF NOT
|
||||
# EXISTS makes this safe on existing databases.
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS "
|
||||
"idx_crawl_queue_pick "
|
||||
"ON crawl_queue(status, priority DESC, depth, "
|
||||
"discovered_at)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS "
|
||||
"idx_page_links_source "
|
||||
"ON page_links(source_url)"
|
||||
)
|
||||
|
||||
# Conditional-GET columns. SQLite has no "ADD COLUMN
|
||||
# IF NOT EXISTS" so swallow the duplicate-column error
|
||||
# when running against an existing database.
|
||||
for column in ("etag TEXT", "last_modified TEXT"):
|
||||
try:
|
||||
cursor.execute(
|
||||
f"ALTER TABLE pages ADD COLUMN {column}"
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
if "duplicate column" not in str(e).lower():
|
||||
raise
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
logger.info(
|
||||
"Metadata database initialized."
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def transaction(self):
|
||||
"""
|
||||
Batch many writes into one commit (one fsync).
|
||||
|
||||
Nested usage is supported: only the outermost block
|
||||
actually issues BEGIN/COMMIT. This lets save_* helpers
|
||||
stay safe to call individually while also being cheap
|
||||
when wrapped in a per-page transaction.
|
||||
"""
|
||||
if self._tx_depth == 0:
|
||||
self.conn.execute("BEGIN")
|
||||
self._tx_depth += 1
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
self._tx_depth -= 1
|
||||
if self._tx_depth == 0:
|
||||
self.conn.rollback()
|
||||
raise
|
||||
else:
|
||||
self._tx_depth -= 1
|
||||
if self._tx_depth == 0:
|
||||
self.conn.commit()
|
||||
|
||||
def _commit(self):
|
||||
"""
|
||||
Commit unless we are inside a batched transaction.
|
||||
"""
|
||||
if self._tx_depth == 0:
|
||||
self.conn.commit()
|
||||
|
||||
def save_page(
|
||||
self,
|
||||
url,
|
||||
title="",
|
||||
markdown_path="",
|
||||
status="success",
|
||||
status_code=200,
|
||||
content_hash="",
|
||||
word_count=0,
|
||||
markdown_length=0,
|
||||
parent_url=None,
|
||||
depth=0,
|
||||
metadata=None,
|
||||
etag=None,
|
||||
last_modified=None,
|
||||
):
|
||||
"""
|
||||
Insert or update page metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
now = _utcnow_iso()
|
||||
|
||||
metadata_json = json.dumps(
|
||||
metadata or {},
|
||||
ensure_ascii=False
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO pages (
|
||||
url,
|
||||
title,
|
||||
markdown_path,
|
||||
status,
|
||||
status_code,
|
||||
content_hash,
|
||||
word_count,
|
||||
markdown_length,
|
||||
crawled_at,
|
||||
updated_at,
|
||||
parent_url,
|
||||
depth,
|
||||
metadata_json,
|
||||
etag,
|
||||
last_modified
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
|
||||
ON CONFLICT(url)
|
||||
DO UPDATE SET
|
||||
title=excluded.title,
|
||||
markdown_path=excluded.markdown_path,
|
||||
status=excluded.status,
|
||||
status_code=excluded.status_code,
|
||||
content_hash=excluded.content_hash,
|
||||
word_count=excluded.word_count,
|
||||
markdown_length=excluded.markdown_length,
|
||||
updated_at=excluded.updated_at,
|
||||
metadata_json=excluded.metadata_json,
|
||||
etag=COALESCE(excluded.etag, pages.etag),
|
||||
last_modified=COALESCE(
|
||||
excluded.last_modified,
|
||||
pages.last_modified
|
||||
)
|
||||
""",
|
||||
(
|
||||
url,
|
||||
title,
|
||||
markdown_path,
|
||||
status,
|
||||
status_code,
|
||||
content_hash,
|
||||
word_count,
|
||||
markdown_length,
|
||||
now,
|
||||
now,
|
||||
parent_url,
|
||||
depth,
|
||||
metadata_json,
|
||||
etag,
|
||||
last_modified,
|
||||
),
|
||||
)
|
||||
|
||||
self._commit()
|
||||
|
||||
logger.debug(
|
||||
f"Saved page metadata: {url}"
|
||||
)
|
||||
|
||||
def get_cached_headers(self, url):
|
||||
"""
|
||||
Return (etag, last_modified) for an already-crawled
|
||||
URL, or (None, None) if unknown.
|
||||
"""
|
||||
cursor = self.conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT etag, last_modified FROM pages WHERE url=?",
|
||||
(url,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None, None
|
||||
return row["etag"], row["last_modified"]
|
||||
|
||||
def save_asset(
|
||||
self,
|
||||
page_url,
|
||||
asset_url,
|
||||
local_path
|
||||
):
|
||||
"""
|
||||
Save asset metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO assets (
|
||||
page_url,
|
||||
asset_url,
|
||||
local_path,
|
||||
downloaded_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
page_url,
|
||||
asset_url,
|
||||
local_path,
|
||||
_utcnow_iso()
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
def save_page_link(
|
||||
self,
|
||||
source_url,
|
||||
target_url,
|
||||
link_type="internal"
|
||||
):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO page_links (
|
||||
source_url,
|
||||
target_url,
|
||||
link_type,
|
||||
discovered_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
source_url,
|
||||
target_url,
|
||||
link_type,
|
||||
_utcnow_iso()
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
def save_discovery_source(
|
||||
self,
|
||||
url,
|
||||
discovery_type,
|
||||
source_url=None
|
||||
):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO discovery_sources (
|
||||
url,
|
||||
discovery_type,
|
||||
source_url,
|
||||
discovered_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
url,
|
||||
discovery_type,
|
||||
source_url,
|
||||
_utcnow_iso()
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
def get_outgoing_links(
|
||||
self,
|
||||
source_url
|
||||
):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT target_url
|
||||
FROM page_links
|
||||
WHERE source_url=?
|
||||
""", (source_url,))
|
||||
|
||||
return [
|
||||
row["target_url"]
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
def export_site_graph(
|
||||
self,
|
||||
output_path
|
||||
):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
source_url,
|
||||
target_url,
|
||||
link_type
|
||||
FROM page_links
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
graph = [
|
||||
dict(row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
with open(
|
||||
output_path,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as f:
|
||||
|
||||
json.dump(
|
||||
graph,
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Exported site graph: {output_path}"
|
||||
)
|
||||
|
||||
def save_failure(
|
||||
self,
|
||||
url,
|
||||
error_message
|
||||
):
|
||||
"""
|
||||
Save failed crawl.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO failures (
|
||||
url,
|
||||
error_message,
|
||||
failed_at
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""", (
|
||||
url,
|
||||
error_message,
|
||||
_utcnow_iso()
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
logger.warning(
|
||||
f"Saved failure: {url}"
|
||||
)
|
||||
|
||||
def enqueue_url(
|
||||
self,
|
||||
url,
|
||||
parent_url=None,
|
||||
depth=0,
|
||||
priority=0
|
||||
):
|
||||
"""
|
||||
Save crawl queue item.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO crawl_queue (
|
||||
url,
|
||||
status,
|
||||
priority,
|
||||
depth,
|
||||
parent_url,
|
||||
discovered_at,
|
||||
attempts
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
url,
|
||||
"pending",
|
||||
priority,
|
||||
depth,
|
||||
parent_url,
|
||||
_utcnow_iso(),
|
||||
0
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
def update_queue_status(
|
||||
self,
|
||||
url,
|
||||
status
|
||||
):
|
||||
"""
|
||||
Update queue item status.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET status=?
|
||||
WHERE url=?
|
||||
""", (
|
||||
status,
|
||||
url
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
def page_exists(
|
||||
self,
|
||||
url
|
||||
):
|
||||
"""
|
||||
Check if page already crawled.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT 1
|
||||
FROM pages
|
||||
WHERE url=?
|
||||
LIMIT 1
|
||||
""", (url,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
return row is not None
|
||||
|
||||
def get_page(
|
||||
self,
|
||||
url
|
||||
):
|
||||
"""
|
||||
Retrieve page metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM pages
|
||||
WHERE url=?
|
||||
""", (url,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return dict(row)
|
||||
|
||||
def get_all_pages(self):
|
||||
"""
|
||||
Get all crawled pages.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM pages
|
||||
ORDER BY crawled_at DESC
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
dict(row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_failed_pages(self):
|
||||
"""
|
||||
Get all failures.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM failures
|
||||
ORDER BY failed_at DESC
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
dict(row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_pending_queue(self):
|
||||
"""
|
||||
Get pending URLs.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM crawl_queue
|
||||
WHERE status='pending'
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
dict(row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_next_pending_url(self):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM crawl_queue
|
||||
WHERE status IN ('pending', 'retry')
|
||||
ORDER BY priority DESC, depth ASC, discovered_at ASC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
def mark_processing(self, url):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET
|
||||
status='processing',
|
||||
started_at=?,
|
||||
attempts=attempts + 1
|
||||
WHERE url=?
|
||||
""", (
|
||||
_utcnow_iso(),
|
||||
url
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
def mark_done(self, url):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET
|
||||
status='done',
|
||||
finished_at=?
|
||||
WHERE url=?
|
||||
""", (
|
||||
_utcnow_iso(),
|
||||
url
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
def mark_failed(self, url, error_message, max_attempts=3):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT attempts
|
||||
FROM crawl_queue
|
||||
WHERE url=?
|
||||
""", (url,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
attempts = row["attempts"] if row else 0
|
||||
|
||||
next_status = "failed" if attempts >= max_attempts else "retry"
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET
|
||||
status=?,
|
||||
last_error=?,
|
||||
finished_at=?
|
||||
WHERE url=?
|
||||
""", (
|
||||
next_status,
|
||||
error_message,
|
||||
_utcnow_iso(),
|
||||
url
|
||||
))
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO failures (
|
||||
url,
|
||||
error_message,
|
||||
failed_at
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""", (
|
||||
url,
|
||||
error_message,
|
||||
_utcnow_iso()
|
||||
))
|
||||
|
||||
self._commit()
|
||||
|
||||
logger.warning(
|
||||
f"Marked URL as {next_status}: {url}"
|
||||
)
|
||||
|
||||
def mark_permanently_failed(self, url, error_message):
|
||||
"""
|
||||
Skip the retry ramp and mark the URL as terminally
|
||||
failed. Used for hard 4xx (e.g. 404) where retrying
|
||||
the same URL will produce the same response.
|
||||
"""
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE crawl_queue
|
||||
SET
|
||||
status='failed',
|
||||
last_error=?,
|
||||
finished_at=?
|
||||
WHERE url=?
|
||||
""",
|
||||
(
|
||||
error_message,
|
||||
_utcnow_iso(),
|
||||
url,
|
||||
),
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO failures (
|
||||
url,
|
||||
error_message,
|
||||
failed_at
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(url, error_message, _utcnow_iso()),
|
||||
)
|
||||
|
||||
self._commit()
|
||||
|
||||
logger.warning(
|
||||
f"Marked URL permanently failed: {url} ({error_message})"
|
||||
)
|
||||
|
||||
def reset_stuck_processing(self):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET status='retry'
|
||||
WHERE status='processing'
|
||||
""")
|
||||
|
||||
affected = cursor.rowcount
|
||||
|
||||
self._commit()
|
||||
|
||||
if affected:
|
||||
logger.warning(
|
||||
f"Recovered {affected} stuck processing URLs."
|
||||
)
|
||||
|
||||
def is_done(self, url):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT 1
|
||||
FROM crawl_queue
|
||||
WHERE url=? AND status='done'
|
||||
LIMIT 1
|
||||
""", (url,))
|
||||
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def is_known(self, url):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT 1
|
||||
FROM crawl_queue
|
||||
WHERE url=?
|
||||
LIMIT 1
|
||||
""", (url,))
|
||||
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def queue_stats(self):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT status, COUNT(*) AS count
|
||||
FROM crawl_queue
|
||||
GROUP BY status
|
||||
""")
|
||||
|
||||
return {
|
||||
row["status"]: row["count"]
|
||||
for row in cursor.fetchall()
|
||||
}
|
||||
|
||||
def delete_page(
|
||||
self,
|
||||
url
|
||||
):
|
||||
"""
|
||||
Remove page metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM pages
|
||||
WHERE url=?
|
||||
""", (url,))
|
||||
|
||||
self._commit()
|
||||
|
||||
def clear_failures(self):
|
||||
"""
|
||||
Clear failure table.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM failures
|
||||
""")
|
||||
|
||||
self._commit()
|
||||
|
||||
def export_pages_json(
|
||||
self,
|
||||
output_path
|
||||
):
|
||||
"""
|
||||
Export all pages metadata.
|
||||
"""
|
||||
|
||||
pages = self.get_all_pages()
|
||||
|
||||
with open(
|
||||
output_path,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as f:
|
||||
|
||||
json.dump(
|
||||
pages,
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Exported metadata JSON: "
|
||||
f"{output_path}"
|
||||
)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close database connection.
|
||||
"""
|
||||
|
||||
self.conn.close()
|
||||
|
||||
logger.info(
|
||||
"Metadata database closed."
|
||||
)
|
||||
314
storage/path_mapper.py
Normal file
314
storage/path_mapper.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import hashlib
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class PathMapper:
|
||||
"""
|
||||
Production-ready URL -> filesystem mapper.
|
||||
|
||||
Handles:
|
||||
- structure preservation
|
||||
- collision prevention
|
||||
- filename sanitization
|
||||
- cross-platform safety
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_dir,
|
||||
include_domain=True,
|
||||
max_filename_length=180
|
||||
):
|
||||
self.output_dir = Path(
|
||||
output_dir
|
||||
)
|
||||
|
||||
self.include_domain = (
|
||||
include_domain
|
||||
)
|
||||
|
||||
self.max_filename_length = (
|
||||
max_filename_length
|
||||
)
|
||||
|
||||
def url_to_path(
|
||||
self,
|
||||
url: str,
|
||||
extension=".md"
|
||||
):
|
||||
"""
|
||||
Convert URL -> local file path.
|
||||
"""
|
||||
|
||||
parsed = urlparse(url)
|
||||
|
||||
domain = parsed.netloc
|
||||
|
||||
path = parsed.path.strip("/")
|
||||
|
||||
# Homepage
|
||||
if not path:
|
||||
|
||||
path = "index"
|
||||
|
||||
# Remove trailing slash
|
||||
path = path.rstrip("/")
|
||||
|
||||
# Remove html extension
|
||||
if path.endswith(".html"):
|
||||
|
||||
path = path[:-5]
|
||||
|
||||
# Handle query params
|
||||
if parsed.query:
|
||||
|
||||
query_hash = self.hash_string(
|
||||
parsed.query
|
||||
)[:10]
|
||||
|
||||
path += f"__{query_hash}"
|
||||
|
||||
# Sanitize each segment
|
||||
segments = []
|
||||
|
||||
for segment in path.split("/"):
|
||||
|
||||
segment = self.sanitize_filename(
|
||||
segment
|
||||
)
|
||||
|
||||
if not segment:
|
||||
segment = "untitled"
|
||||
|
||||
segments.append(segment)
|
||||
|
||||
path = "/".join(segments)
|
||||
|
||||
# Append extension
|
||||
path += extension
|
||||
|
||||
# Include domain
|
||||
if self.include_domain:
|
||||
|
||||
output_path = (
|
||||
self.output_dir /
|
||||
domain /
|
||||
path
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
output_path = (
|
||||
self.output_dir /
|
||||
path
|
||||
)
|
||||
|
||||
# Prevent very long filenames
|
||||
output_path = self.shorten_if_needed(
|
||||
output_path
|
||||
)
|
||||
|
||||
output_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Mapped URL -> path: "
|
||||
f"{url} => {output_path}"
|
||||
)
|
||||
|
||||
return output_path
|
||||
|
||||
def asset_url_to_path(
|
||||
self,
|
||||
url: str
|
||||
):
|
||||
"""
|
||||
Map asset URL to asset path.
|
||||
"""
|
||||
|
||||
return self.url_to_path(
|
||||
url,
|
||||
extension=""
|
||||
)
|
||||
|
||||
def sanitize_filename(
|
||||
self,
|
||||
filename: str
|
||||
):
|
||||
"""
|
||||
Remove filesystem-invalid chars.
|
||||
"""
|
||||
|
||||
invalid_chars = [
|
||||
"<",
|
||||
">",
|
||||
":",
|
||||
"\"",
|
||||
"/",
|
||||
"\\",
|
||||
"|",
|
||||
"?",
|
||||
"*",
|
||||
"\n",
|
||||
"\r",
|
||||
"\t"
|
||||
]
|
||||
|
||||
for char in invalid_chars:
|
||||
|
||||
filename = filename.replace(
|
||||
char,
|
||||
"_"
|
||||
)
|
||||
|
||||
filename = filename.strip()
|
||||
|
||||
# Windows reserved names -- the OS blocks these
|
||||
# regardless of extension, so e.g. "CON.txt" is
|
||||
# also unusable on Windows. Check the stem (the
|
||||
# portion before the first dot) against the full
|
||||
# reserved set: CON, PRN, AUX, NUL, COM1..COM9,
|
||||
# LPT1..LPT9.
|
||||
reserved = {
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
}
|
||||
for i in range(1, 10):
|
||||
reserved.add(f"COM{i}")
|
||||
reserved.add(f"LPT{i}")
|
||||
|
||||
stem = filename.split(".", 1)[0]
|
||||
|
||||
if stem.upper() in reserved:
|
||||
|
||||
filename = "_" + filename
|
||||
|
||||
return filename
|
||||
|
||||
def shorten_if_needed(
|
||||
self,
|
||||
path: Path
|
||||
):
|
||||
"""
|
||||
Prevent excessively long filenames.
|
||||
"""
|
||||
|
||||
filename = path.name
|
||||
|
||||
if len(filename) <= self.max_filename_length:
|
||||
|
||||
return path
|
||||
|
||||
suffix = path.suffix
|
||||
|
||||
stem = path.stem
|
||||
|
||||
hash_part = self.hash_string(
|
||||
filename
|
||||
)[:12]
|
||||
|
||||
shortened = (
|
||||
stem[:100]
|
||||
+ "__"
|
||||
+ hash_part
|
||||
+ suffix
|
||||
)
|
||||
|
||||
return path.with_name(
|
||||
shortened
|
||||
)
|
||||
|
||||
def hash_string(
|
||||
self,
|
||||
value: str
|
||||
):
|
||||
"""
|
||||
Stable hash helper.
|
||||
"""
|
||||
|
||||
return hashlib.md5(
|
||||
value.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
def relative_path(
|
||||
self,
|
||||
path: Path
|
||||
):
|
||||
"""
|
||||
Get relative path from output dir.
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
return path.relative_to(
|
||||
self.output_dir
|
||||
)
|
||||
|
||||
except Exception:
|
||||
|
||||
return path
|
||||
|
||||
def markdown_path_to_url(
|
||||
self,
|
||||
markdown_path: Path
|
||||
):
|
||||
"""
|
||||
Reverse mapping helper.
|
||||
"""
|
||||
|
||||
relative = markdown_path.relative_to(
|
||||
self.output_dir
|
||||
)
|
||||
|
||||
return str(relative)
|
||||
|
||||
def ensure_unique_path(
|
||||
self,
|
||||
path: Path
|
||||
):
|
||||
"""
|
||||
Prevent overwrite collisions.
|
||||
"""
|
||||
|
||||
if not path.exists():
|
||||
|
||||
return path
|
||||
|
||||
counter = 1
|
||||
|
||||
while True:
|
||||
|
||||
candidate = path.with_name(
|
||||
f"{path.stem}_{counter}"
|
||||
f"{path.suffix}"
|
||||
)
|
||||
|
||||
if not candidate.exists():
|
||||
|
||||
return candidate
|
||||
|
||||
counter += 1
|
||||
|
||||
def stats(
|
||||
self
|
||||
):
|
||||
"""
|
||||
Path mapper statistics.
|
||||
"""
|
||||
|
||||
return {
|
||||
"output_dir": str(
|
||||
self.output_dir
|
||||
),
|
||||
"include_domain": (
|
||||
self.include_domain
|
||||
)
|
||||
}
|
||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
481
utils/helpers.py
Normal file
481
utils/helpers.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import json
|
||||
import sqlite3
|
||||
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class MetadataDB:
|
||||
"""
|
||||
Metadata storage layer
|
||||
for crawler state tracking.
|
||||
|
||||
Stores:
|
||||
- crawled pages
|
||||
- crawl status
|
||||
- markdown outputs
|
||||
- assets
|
||||
- hashes
|
||||
- timestamps
|
||||
- incremental update info
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path="data/metadata.db"
|
||||
):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
self.db_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
self.conn = sqlite3.connect(
|
||||
self.db_path
|
||||
)
|
||||
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
|
||||
self.initialize()
|
||||
|
||||
def initialize(self):
|
||||
"""
|
||||
Create required tables.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Pages table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
url TEXT UNIQUE,
|
||||
title TEXT,
|
||||
|
||||
markdown_path TEXT,
|
||||
|
||||
status TEXT,
|
||||
status_code INTEGER,
|
||||
|
||||
content_hash TEXT,
|
||||
|
||||
word_count INTEGER,
|
||||
markdown_length INTEGER,
|
||||
|
||||
crawled_at TEXT,
|
||||
updated_at TEXT,
|
||||
|
||||
parent_url TEXT,
|
||||
depth INTEGER,
|
||||
|
||||
metadata_json TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Assets table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
page_url TEXT,
|
||||
|
||||
asset_url TEXT,
|
||||
local_path TEXT,
|
||||
|
||||
downloaded_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Crawl queue state
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS crawl_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
url TEXT UNIQUE,
|
||||
|
||||
status TEXT,
|
||||
|
||||
discovered_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Failed pages
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS failures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
url TEXT,
|
||||
|
||||
error_message TEXT,
|
||||
|
||||
failed_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
logger.info(
|
||||
"Metadata database initialized."
|
||||
)
|
||||
|
||||
def save_page(
|
||||
self,
|
||||
url,
|
||||
title="",
|
||||
markdown_path="",
|
||||
status="success",
|
||||
status_code=200,
|
||||
content_hash="",
|
||||
word_count=0,
|
||||
markdown_length=0,
|
||||
parent_url=None,
|
||||
depth=0,
|
||||
metadata=None
|
||||
):
|
||||
"""
|
||||
Insert or update page metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
metadata_json = json.dumps(
|
||||
metadata or {},
|
||||
ensure_ascii=False
|
||||
)
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO pages (
|
||||
url,
|
||||
title,
|
||||
markdown_path,
|
||||
status,
|
||||
status_code,
|
||||
content_hash,
|
||||
word_count,
|
||||
markdown_length,
|
||||
crawled_at,
|
||||
updated_at,
|
||||
parent_url,
|
||||
depth,
|
||||
metadata_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
|
||||
ON CONFLICT(url)
|
||||
DO UPDATE SET
|
||||
title=excluded.title,
|
||||
markdown_path=excluded.markdown_path,
|
||||
status=excluded.status,
|
||||
status_code=excluded.status_code,
|
||||
content_hash=excluded.content_hash,
|
||||
word_count=excluded.word_count,
|
||||
markdown_length=excluded.markdown_length,
|
||||
updated_at=excluded.updated_at,
|
||||
metadata_json=excluded.metadata_json
|
||||
""", (
|
||||
url,
|
||||
title,
|
||||
markdown_path,
|
||||
status,
|
||||
status_code,
|
||||
content_hash,
|
||||
word_count,
|
||||
markdown_length,
|
||||
now,
|
||||
now,
|
||||
parent_url,
|
||||
depth,
|
||||
metadata_json
|
||||
))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
logger.info(
|
||||
f"Saved page metadata: {url}"
|
||||
)
|
||||
|
||||
def save_asset(
|
||||
self,
|
||||
page_url,
|
||||
asset_url,
|
||||
local_path
|
||||
):
|
||||
"""
|
||||
Save asset metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO assets (
|
||||
page_url,
|
||||
asset_url,
|
||||
local_path,
|
||||
downloaded_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (
|
||||
page_url,
|
||||
asset_url,
|
||||
local_path,
|
||||
datetime.utcnow().isoformat()
|
||||
))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def save_failure(
|
||||
self,
|
||||
url,
|
||||
error_message
|
||||
):
|
||||
"""
|
||||
Save failed crawl.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO failures (
|
||||
url,
|
||||
error_message,
|
||||
failed_at
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""", (
|
||||
url,
|
||||
error_message,
|
||||
datetime.utcnow().isoformat()
|
||||
))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
logger.warning(
|
||||
f"Saved failure: {url}"
|
||||
)
|
||||
|
||||
def enqueue_url(
|
||||
self,
|
||||
url,
|
||||
status="pending"
|
||||
):
|
||||
"""
|
||||
Save crawl queue item.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO crawl_queue (
|
||||
url,
|
||||
status,
|
||||
discovered_at
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""", (
|
||||
url,
|
||||
status,
|
||||
datetime.utcnow().isoformat()
|
||||
))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def update_queue_status(
|
||||
self,
|
||||
url,
|
||||
status
|
||||
):
|
||||
"""
|
||||
Update queue item status.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE crawl_queue
|
||||
SET status=?
|
||||
WHERE url=?
|
||||
""", (
|
||||
status,
|
||||
url
|
||||
))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def page_exists(
|
||||
self,
|
||||
url
|
||||
):
|
||||
"""
|
||||
Check if page already crawled.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT 1
|
||||
FROM pages
|
||||
WHERE url=?
|
||||
LIMIT 1
|
||||
""", (url,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
return row is not None
|
||||
|
||||
def get_page(
|
||||
self,
|
||||
url
|
||||
):
|
||||
"""
|
||||
Retrieve page metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM pages
|
||||
WHERE url=?
|
||||
""", (url,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return dict(row)
|
||||
|
||||
def get_all_pages(self):
|
||||
"""
|
||||
Get all crawled pages.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM pages
|
||||
ORDER BY crawled_at DESC
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
dict(row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_failed_pages(self):
|
||||
"""
|
||||
Get all failures.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM failures
|
||||
ORDER BY failed_at DESC
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
dict(row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_pending_queue(self):
|
||||
"""
|
||||
Get pending URLs.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT *
|
||||
FROM crawl_queue
|
||||
WHERE status='pending'
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
dict(row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def delete_page(
|
||||
self,
|
||||
url
|
||||
):
|
||||
"""
|
||||
Remove page metadata.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM pages
|
||||
WHERE url=?
|
||||
""", (url,))
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def clear_failures(self):
|
||||
"""
|
||||
Clear failure table.
|
||||
"""
|
||||
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
DELETE FROM failures
|
||||
""")
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def export_pages_json(
|
||||
self,
|
||||
output_path
|
||||
):
|
||||
"""
|
||||
Export all pages metadata.
|
||||
"""
|
||||
|
||||
pages = self.get_all_pages()
|
||||
|
||||
with open(
|
||||
output_path,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as f:
|
||||
|
||||
json.dump(
|
||||
pages,
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Exported metadata JSON: "
|
||||
f"{output_path}"
|
||||
)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close database connection.
|
||||
"""
|
||||
|
||||
self.conn.close()
|
||||
|
||||
logger.info(
|
||||
"Metadata database closed."
|
||||
)
|
||||
98
utils/logger.py
Normal file
98
utils/logger.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
LOG_DIR = Path("logs")
|
||||
|
||||
LOG_DIR.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
|
||||
def setup_logger():
|
||||
"""
|
||||
Configure production logger.
|
||||
"""
|
||||
|
||||
logger.remove()
|
||||
|
||||
# Console logger
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level="INFO",
|
||||
colorize=True,
|
||||
enqueue=True,
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
format=(
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||
"<level>{level: <8}</level> | "
|
||||
"<cyan>{name}</cyan>:"
|
||||
"<cyan>{function}</cyan>:"
|
||||
"<cyan>{line}</cyan> - "
|
||||
"<level>{message}</level>"
|
||||
)
|
||||
)
|
||||
|
||||
# Main crawler log
|
||||
logger.add(
|
||||
LOG_DIR / "crawler.log",
|
||||
level="INFO",
|
||||
rotation="50 MB",
|
||||
retention="30 days",
|
||||
compression="zip",
|
||||
enqueue=True,
|
||||
encoding="utf-8",
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
format=(
|
||||
"{time:YYYY-MM-DD HH:mm:ss} | "
|
||||
"{level: <8} | "
|
||||
"{name}:{function}:{line} - "
|
||||
"{message}"
|
||||
)
|
||||
)
|
||||
|
||||
# Error log
|
||||
logger.add(
|
||||
LOG_DIR / "errors.log",
|
||||
level="ERROR",
|
||||
rotation="20 MB",
|
||||
retention="60 days",
|
||||
compression="zip",
|
||||
enqueue=True,
|
||||
encoding="utf-8",
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
format=(
|
||||
"{time:YYYY-MM-DD HH:mm:ss} | "
|
||||
"{level: <8} | "
|
||||
"{name}:{function}:{line} - "
|
||||
"{message}"
|
||||
)
|
||||
)
|
||||
|
||||
# Debug log: very chatty (every DISCOVERED/ENQUEUED, every
|
||||
# save_*), so only enable when explicitly asked for.
|
||||
# Writing+rotating 100 MB of debug log per run silently
|
||||
# bottlenecks the crawler.
|
||||
if os.environ.get("CRAWLER_DEBUG"):
|
||||
logger.add(
|
||||
LOG_DIR / "debug.log",
|
||||
level="DEBUG",
|
||||
rotation="100 MB",
|
||||
retention="14 days",
|
||||
compression="zip",
|
||||
enqueue=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logger.info("Logger initialized.")
|
||||
|
||||
|
||||
# Initialize automatically
|
||||
setup_logger()
|
||||
275
utils/retry.py
Normal file
275
utils/retry.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import asyncio
|
||||
from functools import wraps
|
||||
import random
|
||||
|
||||
from tenacity import (
|
||||
retry,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
retry_if_exception_type,
|
||||
before_sleep_log
|
||||
)
|
||||
|
||||
from utils.logger import logger
|
||||
|
||||
|
||||
class RetryConfig:
|
||||
"""
|
||||
Global retry configuration.
|
||||
"""
|
||||
|
||||
DEFAULT_ATTEMPTS = 3
|
||||
|
||||
DEFAULT_MIN_WAIT = 1
|
||||
|
||||
DEFAULT_MAX_WAIT = 10
|
||||
|
||||
DEFAULT_JITTER = 0.5
|
||||
|
||||
|
||||
def retry_sync(
|
||||
attempts=RetryConfig.DEFAULT_ATTEMPTS,
|
||||
min_wait=RetryConfig.DEFAULT_MIN_WAIT,
|
||||
max_wait=RetryConfig.DEFAULT_MAX_WAIT,
|
||||
exceptions=(Exception,)
|
||||
):
|
||||
"""
|
||||
Retry decorator for sync functions.
|
||||
|
||||
Example:
|
||||
|
||||
@retry_sync()
|
||||
def task():
|
||||
...
|
||||
"""
|
||||
|
||||
return retry(
|
||||
stop=stop_after_attempt(attempts),
|
||||
|
||||
wait=wait_exponential(
|
||||
multiplier=1,
|
||||
min=min_wait,
|
||||
max=max_wait
|
||||
),
|
||||
|
||||
retry=retry_if_exception_type(
|
||||
exceptions
|
||||
),
|
||||
|
||||
before_sleep=before_sleep_log(
|
||||
logger,
|
||||
"WARNING"
|
||||
),
|
||||
|
||||
reraise=True
|
||||
)
|
||||
|
||||
|
||||
def retry_async(
|
||||
attempts=RetryConfig.DEFAULT_ATTEMPTS,
|
||||
min_wait=RetryConfig.DEFAULT_MIN_WAIT,
|
||||
max_wait=RetryConfig.DEFAULT_MAX_WAIT,
|
||||
jitter=RetryConfig.DEFAULT_JITTER,
|
||||
exceptions=(Exception,)
|
||||
):
|
||||
"""
|
||||
Retry decorator for async functions.
|
||||
|
||||
Example:
|
||||
|
||||
@retry_async()
|
||||
async def fetch():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(
|
||||
1,
|
||||
attempts + 1
|
||||
):
|
||||
|
||||
try:
|
||||
|
||||
logger.debug(
|
||||
f"Attempt {attempt}/"
|
||||
f"{attempts}: "
|
||||
f"{func.__name__}"
|
||||
)
|
||||
|
||||
return await func(
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
except exceptions as e:
|
||||
|
||||
last_exception = e
|
||||
|
||||
logger.warning(
|
||||
f"Retry failed "
|
||||
f"{attempt}/{attempts} "
|
||||
f"for {func.__name__}: {e}"
|
||||
)
|
||||
|
||||
if attempt >= attempts:
|
||||
break
|
||||
|
||||
base_sleep = min(
|
||||
min_wait * (2 ** (attempt - 1)),
|
||||
max_wait
|
||||
)
|
||||
|
||||
sleep_time = base_sleep + random.uniform(
|
||||
0,
|
||||
jitter
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sleeping "
|
||||
f"{sleep_time:.2f}s before retry"
|
||||
)
|
||||
|
||||
await asyncio.sleep(
|
||||
sleep_time
|
||||
)
|
||||
|
||||
logger.error(
|
||||
f"Max retries exceeded "
|
||||
f"for {func.__name__}"
|
||||
)
|
||||
|
||||
raise last_exception
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
async def retry_operation(
|
||||
coro,
|
||||
attempts=3,
|
||||
delay=1,
|
||||
exceptions=(Exception,)
|
||||
):
|
||||
"""
|
||||
Retry arbitrary async coroutine.
|
||||
|
||||
Example:
|
||||
|
||||
result = await retry_operation(
|
||||
fetch_page(url)
|
||||
)
|
||||
"""
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(
|
||||
1,
|
||||
attempts + 1
|
||||
):
|
||||
|
||||
try:
|
||||
|
||||
logger.debug(
|
||||
f"Retry operation "
|
||||
f"attempt {attempt}/{attempts}"
|
||||
)
|
||||
|
||||
return await coro
|
||||
|
||||
except exceptions as e:
|
||||
|
||||
last_exception = e
|
||||
|
||||
logger.warning(
|
||||
f"Retry operation failed "
|
||||
f"{attempt}/{attempts}: {e}"
|
||||
)
|
||||
|
||||
if attempt >= attempts:
|
||||
break
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
logger.error(
|
||||
"Retry operation exceeded "
|
||||
"max attempts."
|
||||
)
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
def safe_execute(
|
||||
func,
|
||||
*args,
|
||||
default=None,
|
||||
log_error=True,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Execute function safely.
|
||||
|
||||
Returns default value if failed.
|
||||
|
||||
Example:
|
||||
|
||||
result = safe_execute(
|
||||
parse_html,
|
||||
html,
|
||||
default=""
|
||||
)
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
return func(
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
if log_error:
|
||||
|
||||
logger.exception(
|
||||
f"Safe execute failed "
|
||||
f"{func.__name__}: {e}"
|
||||
)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
async def safe_execute_async(
|
||||
func,
|
||||
*args,
|
||||
default=None,
|
||||
log_error=True,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Safe async execution.
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
return await func(
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
if log_error:
|
||||
|
||||
logger.exception(
|
||||
f"Safe async execute failed "
|
||||
f"{func.__name__}: {e}"
|
||||
)
|
||||
|
||||
return default
|
||||
830
uv.lock
Normal file
830
uv.lock
Normal file
|
|
@ -0,0 +1,830 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "26.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pytokens" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "intra-mart-doc-crawler"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "httpx" },
|
||||
{ name = "loguru" },
|
||||
{ name = "lxml" },
|
||||
{ name = "markdownify" },
|
||||
{ name = "playwright" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "black" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.12.3" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=24.4.2" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "loguru", specifier = ">=0.7.2" },
|
||||
{ name = "lxml", specifier = ">=5.2.2" },
|
||||
{ name = "markdownify", specifier = ">=0.13.1" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.1" },
|
||||
{ name = "playwright", specifier = ">=1.53.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.2" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.7" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||
{ name = "tenacity", specifier = ">=9.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdownify"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ast-serialize" },
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.60.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenacity"
|
||||
version = "9.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
Loading…
Reference in a new issue