commit 67296cb4a7f9d6fd063f983fd18853c915fa7c65 Author: wehub-resource-sync Date: Mon Jul 13 12:26:53 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..37feb06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ +env/ + +# Git +.git/ +.github/ +.gitignore + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# Documentation +docs/ +*.md +!README.md + +# Data (will be mounted as volume) +.env +data/summaries/ +data/subscribers.json + +# Build artifacts +*.egg-info/ +dist/ +build/ + +# Misc +todo.txt +*.svg +*.tsx +.claude/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2242fdd --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Environment Variables + +# AI API Keys (configure at least one) +ANTHROPIC_API_KEY=sk-ant-xxx +OPENAI_API_KEY=sk-xxx +AZURE_OPENAI_API_KEY=xxx +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +GOOGLE_API_KEY=xxx +MINIMAX_API_KEY=xxx +DASHSCOPE_API_KEY=xxx +DOUBAO_API_KEY=xxx +DEEPSEEK_API_KEY=xxx + +# WebHook Url (optional for sending notifications) +HORIZON_WEBHOOK_URL=https://xxx + +# GitHub Token (optional but recommended for higher rate limits) +# Without token: 60 requests/hour +# With token: 5000 requests/hour +GITHUB_TOKEN=ghp_xxx + +# Apify Token (required for Twitter scraping) +# Get yours at https://console.apify.com/account/integrations +APIFY_TOKEN=apify_api_xxx diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..5ff76e7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +## Summary + +Describe clearly what this PR changes and why. + +## Scope + +- [ ] This PR contains **one feature / one fix / one focused change** +- [ ] This PR does **not** combine multiple unrelated features + +## Changes + +- +- +- + +## Notes for Reviewers + +Anything reviewers should pay attention to? + +## Checklist + +- [ ] I explained the change clearly +- [ ] I kept the PR focused and easy to review +- [ ] I tested the change locally when applicable diff --git a/.github/workflows/daily-summary.yml b/.github/workflows/daily-summary.yml new file mode 100644 index 0000000..09bedcc --- /dev/null +++ b/.github/workflows/daily-summary.yml @@ -0,0 +1,57 @@ +name: Daily Horizon Summary + +on: + schedule: + # GitHub scheduled workflows use UTC. 20:00 UTC is 04:00 Asia/Shanghai; + # this intentionally runs early to offset the observed schedule delay. + - cron: '0 22 * * *' + workflow_dispatch: # Allow manual trigger + +jobs: + daily-summary: + runs-on: ubuntu-latest + permissions: + contents: write # Needed to commit changes + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync + + - name: Prepare GitHub Actions config + run: cp data/config.github.json data/config.json + + - name: Run Horizon + env: + # Map secrets to environment variables expected by config.json + # Modify these based on your data/config.github.json provider settings + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + LWN_KEY: ${{ secrets.LWN_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + HORIZON_WEBHOOK_URL: ${{ secrets.HORIZON_WEBHOOK_URL }} + run: uv run horizon --hours 24 + + - name: Set current date as environment variable + run: echo "DATE=$(date +'%Y-%m-%d')" >> $GITHUB_ENV + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + keep_files: true # Keep existing summaries on gh-pages branch + commit_message: "📝 Daily Summary: ${{ env.DATE }}" + publish_branch: gh-pages + enable_jekyll: true diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..3c4c69b --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,27 @@ +name: Deploy Docs + +on: + push: + branches: [main] + paths: ['docs/**'] + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + keep_files: true + publish_branch: gh-pages + enable_jekyll: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f8a23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env + +# Data files (keep structure, ignore content) +data/config.json +data/seen.json +data/summaries/*.md +data/subscribers.json +data/mcp.secrets.json +data/mcp-secrets.json +data/mcp-runs/ +data/x_cookies_*.json + +docs/_posts/*.md + +# Logs +logs/*.log +*.log + +# macOS +.DS_Store + +# uv +.uv/ + +# todo +todo.txt +.ruff_cache +.pytest_cache \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7e58d8d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +thysrael@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f80da68 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to Horizon + +Thanks for your interest in contributing to Horizon. + +## Ways to Contribute + +You can contribute in more than one way: + +- Report bugs or suggest features by opening an issue +- Improve code, documentation, or examples through pull requests +- Share valuable news sources with the community through the website + +## Code Contributions + +If you want to contribute code or docs: + +1. Fork the repository +2. Create a new branch +3. Make your changes +4. Open a pull request with a clear description + +Please keep pull requests focused and easy to review. + +## Share Sources + +Horizon also welcomes **source contributions**, not just code. + +If you discover high-quality sources worth sharing with other users, please submit them via **[horizon1123.top](https://horizon1123.top)**. + +Good examples include: + +- niche RSS or Atom feeds +- valuable Hacker News or Reddit sources +- notable GitHub repositories or release sources +- high-signal Telegram channels +- other reliable tech news sources + +## Before You Submit + +Please make sure your contribution is: + +- relevant to Horizon users +- clear and well-described +- respectful of copyright and platform rules + +## Questions + +If you are unsure whether something fits, feel free to open an issue first. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..222b3a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Use Python 3.11 slim image +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install uv for faster dependency management +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy project files +COPY pyproject.toml uv.lock README.md ./ +COPY src ./src +COPY data ./data +COPY .env.example .env.example + +# Install dependencies +RUN uv sync --frozen --no-dev + +# Create volume mount points +VOLUME ["/app/data"] + +# Set environment variables +ENV PYTHONUNBUFFERED=1 + +# Run the application +ENTRYPOINT ["uv", "run", "horizon"] +CMD [] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bbc1ed8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thysrael + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1862177 --- /dev/null +++ b/README.md @@ -0,0 +1,405 @@ +
+

🌅 Horizon

+ +

Enjoy the News itself. Leave others to Horizon

+ +Thysrael%2FHorizon | Trendshift +Thysrael%2FHorizon | Trendshift +Featured|HelloGitHub +
+ +[![License](https://img.shields.io/badge/license-MIT-green.svg?style=for-the-badge&logo=opensourceinitiative&logoColor=white)](LICENSE) +[![Tool uv](https://img.shields.io/badge/Tool-uv-4B275F?style=for-the-badge&logo=uv&logoColor=white)](https://github.com/astral-sh/uv) +[![Website](https://img.shields.io/badge/Website-Horizon-263238?style=for-the-badge&logo=homepage&logoColor=white)](https://www.horizon1123.top/) +[![Daily](https://img.shields.io/github/actions/workflow/status/Thysrael/Horizon/deploy-docs.yml?branch=main&label=Daily&style=for-the-badge&logo=date-fns&logoColor=white)](https://thysrael.github.io/Horizon/) +[![Commit](https://img.shields.io/github/commit-activity/m/Thysrael/Horizon?label=Commit&style=for-the-badge&logo=github&logoColor=white)](https://github.com/Thysrael/Horizon/commits/main) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/Thysrael/Horizon/pulls) +![Sources Welcome](https://img.shields.io/badge/sources-welcome-f97316?style=for-the-badge&logo=rss&logoColor=white) + +![Claude](https://img.shields.io/badge/Claude-f0daba?style=flat-square&logo=anthropic&logoColor=black) +![GPT](https://img.shields.io/badge/GPT-10A37F?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBmaWxsPSJ3aGl0ZSIgZD0iTTIyLjI4MTkgOS44MjExYTUuOTg0NyA1Ljk4NDcgMCAwIDAtLjUxNTctNC45MTA4IDYuMDQ2MiA2LjA0NjIgMCAwIDAtNi41MDk4LTIuOUE2LjA2NTEgNi4wNjUxIDAgMCAwIDQuOTgwNyA0LjE4MThhNS45ODQ3IDUuOTg0NyAwIDAgMC0zLjk5NzcgMi45IDYuMDQ2MiA2LjA0NjIgMCAwIDAgLjc0MjcgNy4wOTY2IDUuOTggNS45OCAwIDAgMCAuNTExIDQuOTEwNyA2LjA1MSA2LjA1MSAwIDAgMCA2LjUxNDYgMi45MDAxQTUuOTg0NyA1Ljk4NDcgMCAwIDAgMTMuMjU5OSAyNGE2LjA1NTcgNi4wNTU3IDAgMCAwIDUuNzcxOC00LjIwNTggNS45ODk0IDUuOTg5NCAwIDAgMCAzLjk5NzctMi45MDAxIDYuMDU1NyA2LjA1NTcgMCAwIDAtLjc0NzUtNy4wNzI5em0tOS4wMjIgMTIuNjA4MWE0LjQ3NTUgNC40NzU1IDAgMCAxLTIuODc2NC0xLjA0MDhsLjE0MTktLjA4MDQgNC43NzgzLTIuNzU4MmEuNzk0OC43OTQ4IDAgMCAwIC4zOTI3LS42ODEzdi02LjczNjlsMi4wMiAxLjE2ODZhLjA3MS4wNzEgMCAwIDEgLjAzOC4wNTJ2NS41ODI2YTQuNTA0IDQuNTA0IDAgMCAxLTQuNDk0NSA0LjQ5NDR6bS05LjY2MDctNC4xMjU0YTQuNDcwOCA0LjQ3MDggMCAwIDEtLjUzNDYtMy4wMTM3bC4xNDIuMDg1MiA0Ljc4MyAyLjc1ODJhLjc3MTIuNzcxMiAwIDAgMCAuNzgwNiAwbDUuODQyOC0zLjM2ODV2Mi4zMzI0YS4wODA0LjA4MDQgMCAwIDEtLjAzMzIuMDYxNUw5Ljc0IDE5Ljk1MDJhNC40OTkyIDQuNDk5MiAwIDAgMS02LjE0MDgtMS42NDY0ek0yLjM0MDggNy44OTU2YTQuNDg1IDQuNDg1IDAgMCAxIDIuMzY1NS0xLjk3MjhWMTEuNmEuNzY2NC43NjY0IDAgMCAwIC4zODc5LjY3NjVsNS44MTQ0IDMuMzU0My0yLjAyMDEgMS4xNjg1YS4wNzU3LjA3NTcgMCAwIDEtLjA3MSAwbC00LjgzMDMtMi43ODY1QTQuNTA0IDQuNTA0IDAgMCAxIDIuMzQwOCA3Ljg3MnptMTYuNTk2MyAzLjg1NThMMTMuMTAzOCA4LjM2NCAxNS4xMTkyIDcuMmEuMDc1Ny4wNzU3IDAgMCAxIC4wNzEgMGw0LjgzMDMgMi43OTEzYTQuNDk0NCA0LjQ5NDQgMCAwIDEtLjY3NjUgOC4xMDQydi01LjY3NzJhLjc5Ljc5IDAgMCAwLS40MDctLjY2N3ptMi4wMTA3LTMuMDIzMWwtLjE0Mi0uMDg1Mi00Ljc3MzUtMi43ODE4YS43NzU5Ljc3NTkgMCAwIDAtLjc4NTQgMEw5LjQwOSA5LjIyOTdWNi44OTc0YS4wNjYyLjA2NjIgMCAwIDEgLjAyODQtLjA2MTVsNC44MzAzLTIuNzg2NmE0LjQ5OTIgNC40OTkyIDAgMCAxIDYuNjgwMiA0LjY2ek04LjMwNjUgMTIuODYzbC0yLjAyLTEuMTYzOGEuMDgwNC4wODA0IDAgMCAxLS4wMzgtLjA1NjdWNi4wNzQyYTQuNDk5MiA0LjQ5OTIgMCAwIDEgNy4zNzU3LTMuNDUzN2wtLjE0Mi4wODA1TDguNzA0IDUuNDU5YS43OTQ4Ljc5NDggMCAwIDAtLjM5MjcuNjgxM3ptMS4wOTc2LTIuMzY1NGwyLjYwMi0xLjQ5OTggMi42MDY5IDEuNDk5OHYyLjk5OTRsLTIuNTk3NCAxLjQ5OTctMi42MDY3LTEuNDk5N1oiLz48L3N2Zz4=) +![Gemini](https://img.shields.io/badge/Gemini-8E75B2?style=flat-square&logo=googlegemini&logoColor=white) +![DeepSeek](https://img.shields.io/badge/DeepSeek-0A6DC2?style=flat-square&logo=deepseek&logoColor=white) +![Doubao](https://img.shields.io/badge/Doubao-00D6C2?style=flat-square&logo=bytedance&logoColor=white) +![MiniMax](https://img.shields.io/badge/MiniMax-FF6F00?style=flat-square&logo=minimax&logoColor=white) +![OpenClaw](https://img.shields.io/badge/OpenClaw-C83232?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDE2IDE2IiBhcmlhLWxhYmVsPSJQaXhlbCBsb2JzdGVyIj4KICA8cmVjdCB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiLz4KICAKICA8ZyBmaWxsPSIjM2EwYTBkIj4KICAgIDxyZWN0IHg9IjEiIHk9IjUiIHdpZHRoPSIxIiBoZWlnaHQ9IjMiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjgiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjkiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjQiIHk9IjIiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjQiIHk9IjEwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSI1IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMSIgeT0iMiIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTIiIHk9IjMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEyIiB5PSI5IiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMyIgeT0iNCIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTMiIHk9IjgiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI1IiB3aWR0aD0iMSIgaGVpZ2h0PSIzIi8+CiAgICA8cmVjdCB4PSI1IiB5PSIxMSIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iMTIiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjExIiB5PSIxMiIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMyIgeT0iMTMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEyIiB5PSIxMyIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iMTQiIHdpZHRoPSI2IiBoZWlnaHQ9IjEiLz4KICA8L2c+CgogIAogIDxnIGZpbGw9IiNmZjRmNDAiPgogICAgPHJlY3QgeD0iNSIgeT0iMyIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjgiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMyIgeT0iNSIgd2lkdGg9IjEwIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjYiIHdpZHRoPSIxMCIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIzIiB5PSI3IiB3aWR0aD0iMTAiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iOCIgd2lkdGg9IjgiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iOSIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iMTIiIHdpZHRoPSI2IiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjYiIHk9IjEzIiB3aWR0aD0iNCIgaGVpZ2h0PSIxIi8+CiAgPC9nPgoKICAKICA8ZyBmaWxsPSIjZmY3NzVmIj4KICAgIDxyZWN0IHg9IjEiIHk9IjYiIHdpZHRoPSIyIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjUiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEzIiB5PSI2IiB3aWR0aD0iMiIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMyIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTMiIHk9IjciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICA8L2c+CgogIAogIDxnIGZpbGw9IiMwODEwMTYiPgogICAgPHJlY3QgeD0iNiIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iOSIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogIDwvZz4KICA8ZyBmaWxsPSIjZjVmYmZmIj4KICAgIDxyZWN0IHg9IjYiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjkiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICA8L2c+Cjwvc3ZnPgoK) +![Ollama](https://img.shields.io/badge/Ollama-FFFFFF?style=flat-square&logo=Ollama&logoColor=black) + +📡 Your own AI-powered news radar. Generates daily briefings in English & Chinese. | 构建你专属的 AI 新闻雷达 + +[📖 Live Demo](https://thysrael.github.io/Horizon/) · [📋 Configuration Guide](https://thysrael.github.io/Horizon/configuration) · [简体中文](README_zh.md) · [日本語](README_ja.md) + +
+ +## Screenshots + + + + + + +
+

Ranked Daily Briefing

+Daily Overview +
+

Context, Summary & Discussion

+News Detail +
+ +
+More Screenshots +
+ + + + + + +
+

Terminal Output

+Terminal Output +
+

Feishu Notification

+Feishu Notification +
+

Email Delivery

+Email Delivery +
+
+ +## Why Horizon? + +Good news is scattered; bad news is endless. Horizon gives you a personal first pass over Hacker News, Reddit, Telegram, RSS, and GitHub: it fetches, deduplicates, scores, filters, and enriches stories with background context and community discussion. + +But Horizon is not just another summarizer. AI is great at reducing noise, but news still needs human taste: the sources you trust, the comments that change how you read a story, and the hidden gems only people can share. Horizon keeps that human layer in the loop with customizable sources, thresholds, models, languages, delivery channels, comment summaries, and a community source hub. + +## Features + +- **📡 Watch Your Own Sources** — Track Hacker News, RSS, Reddit, Telegram, Twitter/X, GitHub releases or user activity, and OpenBB financial news watchlists in one pipeline +- **🤖 Turn Noise Into a Reading List** — Score each item from 0-10 with Claude, GPT, Gemini, DeepSeek, Doubao, MiniMax, Ollama, or any OpenAI-compatible API +- **🔗 Merge Repeated Stories** — Deduplicate the same story across platforms before it reaches your briefing +- **🔍 Understand the Background** — Add web-researched context for unfamiliar concepts, companies, projects, and technical terms +- **💬 Read the Conversation** — Collect and summarize community comments from Hacker News, Reddit, and other supported sources +- **🌐 Publish in Two Languages** — Generate English and Chinese daily briefings from the same source set +- **📝 Ship a Daily Site** — Publish generated Markdown as a GitHub Pages daily briefing site +- **📧 Deliver by Email** — Run a self-hosted SMTP/IMAP newsletter with automatic subscribe and unsubscribe handling +- **🔔 Push to Chat or Automations** — Send templated results to Feishu/Lark, DingTalk, Slack, Discord, or custom webhook endpoints +- **🧙 Start From Your Interests** — Use the setup wizard to generate a personalized source configuration +- **⚙️ Tune the Radar** — Customize sources, thresholds, models, languages, and delivery channels from one JSON config + +## How It Works + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "fontFamily": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + "fontSize": "18px", + "primaryTextColor": "#2d2a3e", + "primaryBorderColor": "#e0dbd3", + "lineColor": "#7c7891", + "tertiaryColor": "#faf8f5", + "clusterBkg": "#f3f0eb", + "clusterBorder": "#e0dbd3" + } +}}%% +flowchart LR + classDef config fill:#fbbf24,stroke:#d4a017,color:#2d2a3e,stroke-width:1.5px; + classDef source fill:#ede7fb,stroke:#6d4aaa,color:#2d2a3e,stroke-width:1.5px; + classDef process fill:#ffe8db,stroke:#e0652e,color:#2d2a3e,stroke-width:1.5px; + classDef output fill:#f9d7e5,stroke:#be185d,color:#2d2a3e,stroke-width:1.5px; + + config["⚙️ Config
sources, thresholds, models, outputs"] + + subgraph sources["Configured Sources"] + rss["📡 RSS"] + hn["📰 Hacker News"] + reddit["💬 Reddit"] + telegram["✈️ Telegram"] + twitter["🐦 Twitter / X"] + github["🐙 GitHub"] + openbb["💹 OpenBB"] + end + + fetch["📥 Fetch"] + dedup["🧹 Deduplicate"] + score["🤖 AI Score & Filter"] + enrich["🔎 Enrich"] + summary["📝 Summarize"] + + subgraph outputs["Outputs"] + direction TB + site["🌐 Pages"] + email["📧 Email"] + webhook["🔔 Webhooks"] + mcp["🧩 MCP"] + end + + config --> fetch + rss --> fetch + hn --> fetch + reddit --> fetch + telegram --> fetch + twitter --> fetch + github --> fetch + openbb --> fetch + + fetch --> dedup --> score --> enrich --> summary + config --> score + config --> summary + config --> outputs + + summary --> site + summary --> email + summary --> webhook + summary --> mcp + + class config config + class rss,hn,reddit,telegram,twitter,github,openbb source + class fetch,dedup,score,enrich,summary process + class site,email,webhook,mcp output +``` + +1. **Define** — Configure sources, thresholds, models, languages, and delivery from one JSON config. +2. **Fetch** — Pull latest content from all configured sources concurrently. +3. **Deduplicate** — Merge items pointing to the same story or URL across platforms. +4. **Score & Filter** — Use AI to rank items and keep only those above your threshold. +5. **Enrich** — Search the web for background context and collect community discussion for important items. +6. **Summarize** — Generate a structured Markdown briefing with summaries, tags, and references. +7. **Deliver** — Publish the result to GitHub Pages, email, webhooks such as Feishu, MCP, or local files. + +## Quick Start + +### 1. Install + +**Option A: Local Installation** + +```bash +git clone https://github.com/Thysrael/Horizon.git +cd Horizon + +# Install with uv (recommended) +uv sync + +# Install test/development extras when needed +uv sync --extra dev + +# Or with pip +pip install -e . +``` + +`dev` is currently defined as an optional extra in `pyproject.toml`, so use `uv sync --extra dev` for pytest and other development dependencies. + +If you want the optional OpenBB financial-news source, install its extra too: + +```bash +uv sync --extra openbb +``` + +If `openbb` pulls packages without wheels on your machine, install the SDK manually with binaries only: + +```bash +uv pip install --only-binary=:all: openbb openbb-benzinga +``` + +**Option B: Docker** + +```bash +git clone https://github.com/Thysrael/Horizon.git +cd Horizon + +# Configure environment +cp .env.example .env +cp data/config.example.json data/config.json +# Edit .env and data/config.json with your API keys and preferences + +# Run with Docker Compose +docker compose run --rm horizon + +# Or run with custom time window +docker compose run --rm horizon --hours 48 +``` + +### 2. Configure + +**Option A: Interactive wizard (recommended)** + +```bash +uv run horizon-wizard +``` + +The wizard asks about your interests (e.g. "LLM inference", "嵌入式", "web security") and auto-generates `data/config.json`. + +**Option B: Manual configuration** + +```bash +cp .env.example .env # Add your API keys +cp data/config.example.json data/config.json # Customize your sources +``` + +Minimal manual configuration: + +```jsonc +{ + "ai": { + "provider": "openai", + "model": "gpt-4", + "api_key_env": "OPENAI_API_KEY" + }, + "sources": { + "rss": [ + { "name": "Simon Willison", "url": "https://simonwillison.net/atom/everything/" } + ] + }, + "filtering": { + "ai_score_threshold": 6.0 + } +} +``` + +**Balanced digest (optional)** + +Limit the final digest size and prevent one category from dominating the +results. Categories come from source configuration such as +`sources.rss[].category`. + +```jsonc +{ + "filtering": { + "ai_score_threshold": 6.0, + "max_items": 20, + "category_groups": { + "ai": { + "limit": 5, + "categories": ["ai-news", "ai-tools", "machine-learning"] + }, + "finance": { + "limit": 5, + "categories": ["finance", "business", "equities"] + } + }, + "default_group": "other", + "default_group_limit": 3 + } +} +``` + +Group limits are applied after AI score filtering and before enrichment. If +`category_groups` and `max_items` are omitted, filtering behaves as before. + +`api_key_env` must be the name of an environment variable, not the API key +itself. Put the real secret in `.env`: + +```bash +OPENAI_API_KEY=sk-your-key +``` + +For Gemini, use `GOOGLE_API_KEY`: + +```jsonc +{ + "ai": { + "provider": "gemini", + "model": "gemini-2.0-flash", + "api_key_env": "GOOGLE_API_KEY" + } +} +``` + +Any string value in `data/config.json` can reference environment variables with `${VAR_NAME}`. This is useful for values such as `ai.base_url`, private RSS feed URLs, webhook endpoints, or custom header templates. + +For the full reference, see the [Configuration Guide](docs/configuration.md). + +### 3. Run + +#### Local Installation + +```bash +uv run horizon # Run with default 24h window +uv run horizon --hours 48 # Fetch from last 48 hours +``` + +#### With Docker + +```bash +docker compose run --rm horizon # Run with default 24h window +docker compose run --rm horizon --hours 48 # Fetch from last 48 hours +``` + +The generated report will be saved to `data/summaries/`. + +### 4. Automate (Optional) + +Horizon works great as a **GitHub Actions** cron job. See [`.github/workflows/daily-summary.yml`](.github/workflows/daily-summary.yml) for a ready-to-use workflow that generates and deploys your daily briefing to GitHub Pages automatically. + +## Supported Sources + +| Source | What it fetches | Comments | +|--------|----------------|----------| +| **Hacker News** | Top stories by score | Yes (top N comments) | +| **RSS / Atom** | Any RSS or Atom feed | — | +| **Reddit** | Subreddits + user posts | Yes (top N comments) | +| **Telegram** | Public channel messages | — | +| **Twitter / X** | Tweets from specific users | Yes (top N replies) | +| **GitHub** | User events & repo releases | — | +| **OpenBB** | Financial company news by watchlist/provider | — | + +## Where Your Briefing Goes + +Horizon can publish or deliver the generated briefing in several ways: + +| Channel | What it does | +|---------|--------------| +| **GitHub Pages Daily Site** | Copies generated Markdown into `docs/` so GitHub Pages can publish a daily-updated briefing site | +| **Email Subscription** | Sends the daily briefing to subscribers and handles subscribe/unsubscribe requests through SMTP/IMAP | +| **Webhook Notification** | Pushes success or failure results to Feishu/Lark, DingTalk, Slack, Discord, or any custom webhook endpoint | +| **MCP Server** | Exposes Horizon pipeline steps as tools so AI assistants can fetch, score, filter, enrich, summarize, and run the full workflow | + +For setup details, see the [Configuration Guide](docs/configuration.md). For MCP tool references and client setup, see [`src/mcp/README.md`](src/mcp/README.md) and [`src/mcp/integration.md`](src/mcp/integration.md). + +## Supported By + +Horizon is an open-source project maintained in spare time. If you'd like to support the project or be listed here, feel free to [open an issue](https://github.com/Thysrael/Horizon/issues/new) or [email me](mailto:thysrael@163.com). + +| Supporter | Details | +|-----------|---------| +| [Compshare / 优云智算](https://www.compshare.cn/?ytag=GPU_YY_git_Horizon) | Compshare currently supports Horizon. Compshare is UCloud's AI cloud platform, offering cost-effective monthly and pay-as-you-go domestic model agent plans starting from RMB 49/month, as well as stable officially relayed overseas models. It supports Claude Code, Codex, and API usage, with enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing.

Register through their [link](https://www.compshare.cn/?ytag=GPU_YY_git_Horizon) to receive a free RMB 5 trial credit. | + +## Documentation + +| Guide | Description | +|-------|-------------| +| [Configuration](docs/configuration.md) | AI providers, sources, filtering, email, webhook, GitHub Pages, and MCP setup | +| [Scoring](docs/scoring.md) | How Horizon evaluates and ranks news items | +| [Scrapers](docs/scrapers.md) | Source scraper details and extension notes | +| [Extractors](docs/extractors.md) | Full article extraction for RSS sources | +| [MCP Tools](src/mcp/README.md) | Tool reference for MCP-compatible clients | + +## Project Status + +Horizon already supports the full daily briefing loop: multi-source collection, AI scoring, deduplication, enrichment, comment summaries, bilingual generation, GitHub Pages publishing, email delivery, webhook delivery, Docker deployment, MCP integration, and the setup wizard. + +Planned improvements: + +- More source types, such as Discord +- Custom scoring prompts per source +- Publish releases on GitHub +- Publish the package to PyPI for `pip install` + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for code, documentation, and source-sharing guidelines. + +### Share Sources + +Want to share valuable source discoveries with the Horizon community? Please submit them through **[horizon1123.top](https://horizon1123.top)**. + +## Acknowledgements + +- Special thanks to [LINUX.DO](https://linux.do/) for providing a promotion platform. +- Special thanks to [HelloGitHub](https://hellogithub.com/) for valuable guidance and suggestions. +- Special thanks to [AIGC Link](https://xhslink.com/m/80ngts127cA) for the promotions on XiaoHongShu. + +## License + +[MIT](LICENSE) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..cd06cb1 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Thysrael/Horizon` +- 原始仓库:https://github.com/Thysrael/Horizon +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 0000000..d8c8d6e --- /dev/null +++ b/README_ja.md @@ -0,0 +1,402 @@ +
+ +

🌅 Horizon

+ +

ニュースそのものを楽しもう。あとはHorizonにおまかせを

+ +Thysrael%2FHorizon | Trendshift +Thysrael%2FHorizon | Trendshift +Featured|HelloGitHub +
+ +[![License](https://img.shields.io/badge/license-MIT-green.svg?style=for-the-badge&logo=opensourceinitiative&logoColor=white)](LICENSE) +[![Tool uv](https://img.shields.io/badge/Tool-uv-4B275F?style=for-the-badge&logo=uv&logoColor=white)](https://github.com/astral-sh/uv) +[![Website](https://img.shields.io/badge/Website-Horizon-263238?style=for-the-badge&logo=homepage&logoColor=white)](https://www.horizon1123.top/) +[![Daily](https://img.shields.io/github/actions/workflow/status/Thysrael/Horizon/deploy-docs.yml?branch=main&label=Daily&style=for-the-badge&logo=date-fns&logoColor=white)](https://thysrael.github.io/Horizon/) +[![Commit](https://img.shields.io/github/commit-activity/m/Thysrael/Horizon?label=Commit&style=for-the-badge&logo=github&logoColor=white)](https://github.com/Thysrael/Horizon/commits/main) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/Thysrael/Horizon/pulls) +![Sources Welcome](https://img.shields.io/badge/sources-welcome-f97316?style=for-the-badge&logo=rss&logoColor=white) + +![Claude](https://img.shields.io/badge/Claude-f0daba?style=flat-square&logo=anthropic&logoColor=black) +![GPT](https://img.shields.io/badge/GPT-10A37F?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBmaWxsPSJ3aGl0ZSIgZD0iTTIyLjI4MTkgOS44MjExYTUuOTg0NyA1Ljk4NDcgMCAwIDAtLjUxNTctNC45MTA4IDYuMDQ2MiA2LjA0NjIgMCAwIDAtNi41MDk4LTIuOUE2LjA2NTEgNi4wNjUxIDAgMCAwIDQuOTgwNyA0LjE4MThhNS45ODQ3IDUuOTg0NyAwIDAgMC0zLjk5NzcgMi45IDYuMDQ2MiA2LjA0NjIgMCAwIDAgLjc0MjcgNy4wOTY2IDUuOTggNS45OCAwIDAgMCAuNTExIDQuOTEwNyA2LjA1MSA2LjA1MSAwIDAgMCA2LjUxNDYgMi45MDAxQTUuOTg0NyA1Ljk4NDcgMCAwIDAgMTMuMjU5OSAyNGE2LjA1NTcgNi4wNTU3IDAgMCAwIDUuNzcxOC00LjIwNTggNS45ODk0IDUuOTg5NCAwIDAgMCAzLjk5NzctMi45MDAxIDYuMDU1NyA2LjA1NTcgMCAwIDAtLjc0NzUtNy4wNzI5em0tOS4wMjIgMTIuNjA4MWE0LjQ3NTUgNC40NzU1IDAgMCAxLTIuODc2NC0xLjA0MDhsLjE0MTktLjA4MDQgNC43NzgzLTIuNzU4MmEuNzk0OC43OTQ4IDAgMCAwIC4zOTI3LS42ODEzdi02LjczNjlsMi4wMiAxLjE2ODZhLjA3MS4wNzEgMCAwIDEgLjAzOC4wNTJ2NS41ODI2YTQuNTA0IDQuNTA0IDAgMCAxLTQuNDk0NSA0LjQ5NDR6bS05LjY2MDctNC4xMjU0YTQuNDcwOCA0LjQ3MDggMCAwIDEtLjUzNDYtMy4wMTM3bC4xNDIuMDg1MiA0Ljc4MyAyLjc1ODJhLjc3MTIuNzcxMiAwIDAgMCAuNzgwNiAwbDUuODQyOC0zLjM2ODV2Mi4zMzI0YS4wODA0LjA4MDQgMCAwIDEtLjAzMzIuMDYxNUw5Ljc0IDE5Ljk1MDJhNC40OTkyIDQuNDk5MiAwIDAgMS02LjE0MDgtMS42NDY0ek0yLjM0MDggNy44OTU2YTQuNDg1IDQuNDg1IDAgMCAxIDIuMzY1NS0xLjk3MjhWMTEuNmEuNzY2NC43NjY0IDAgMCAwIC4zODc5LjY3NjVsNS44MTQ0IDMuMzU0My0yLjAyMDEgMS4xNjg1YS4wNzU3LjA3NTcgMCAwIDEtLjA3MSAwbC00LjgzMDMtMi43ODY1QTQuNTA0IDQuNTA0IDAgMCAxIDIuMzQwOCA3Ljg3MnptMTYuNTk2MyAzLjg1NThMMTMuMTAzOCA4LjM2NCAxNS4xMTkyIDcuMmEuMDc1Ny4wNzU3IDAgMCAxIC4wNzEgMGw0LjgzMDMgMi43OTEzYTQuNDk0NCA0LjQ5NDQgMCAwIDEtLjY3NjUgOC4xMDQydi01LjY3NzJhLjc5Ljc5IDAgMCAwLS40MDctLjY2N3ptMi4wMTA3LTMuMDIzMWwtLjE0Mi0uMDg1Mi00Ljc3MzUtMi43ODE4YS43NzU5Ljc3NTkgMCAwIDAtLjc4NTQgMEw5LjQwOSA5LjIyOTdWNi44OTc0YS4wNjYyLjA2NjIgMCAwIDEgLjAyODQtLjA2MTVsNC44MzAzLTIuNzg2NmE0LjQ5OTIgNC40OTkyIDAgMCAxIDYuNjgwMiA0LjY2ek04LjMwNjUgMTIuODYzbC0yLjAyLTEuMTYzOGEuMDgwNC4wODA0IDAgMCAxLS4wMzgtLjA1NjdWNi4wNzQyYTQuNDk5MiA0LjQ5OTIgMCAwIDEgNy4zNzU3LTMuNDUzN2wtLjE0Mi4wODA1TDguNzA0IDUuNDU5YS43OTQ4Ljc5NDggMCAwIDAtLjM5MjcuNjgxM3ptMS4wOTc2LTIuMzY1NGwyLjYwMi0xLjQ5OTggMi42MDY5IDEuNDk5OHYyLjk5OTRsLTIuNTk3NCAxLjQ5OTctMi42MDY3LTEuNDk5N1oiLz48L3N2Zz4=) +![Gemini](https://img.shields.io/badge/Gemini-8E75B2?style=flat-square&logo=googlegemini&logoColor=white) +![DeepSeek](https://img.shields.io/badge/DeepSeek-0A6DC2?style=flat-square&logo=deepseek&logoColor=white) +![Doubao](https://img.shields.io/badge/Doubao-00D6C2?style=flat-square&logo=bytedance&logoColor=white) +![MiniMax](https://img.shields.io/badge/MiniMax-FF6F00?style=flat-square&logo=minimax&logoColor=white) +![OpenClaw](https://img.shields.io/badge/OpenClaw-C83232?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDE2IDE2IiBhcmlhLWxhYmVsPSJQaXhlbCBsb2JzdGVyIj4KICA8cmVjdCB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiLz4KICAKICA8ZyBmaWxsPSIjM2EwYTBkIj4KICAgIDxyZWN0IHg9IjEiIHk9IjUiIHdpZHRoPSIxIiBoZWlnaHQ9IjMiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjgiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjkiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjQiIHk9IjIiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjQiIHk9IjEwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSI1IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMSIgeT0iMiIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTIiIHk9IjMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEyIiB5PSI5IiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMyIgeT0iNCIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTMiIHk9IjgiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI1IiB3aWR0aD0iMSIgaGVpZ2h0PSIzIi8+CiAgICA8cmVjdCB4PSI1IiB5PSIxMSIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iMTIiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjExIiB5PSIxMiIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMyIgeT0iMTMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEyIiB5PSIxMyIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iMTQiIHdpZHRoPSI2IiBoZWlnaHQ9IjEiLz4KICA8L2c+CgogIAogIDxnIGZpbGw9IiNmZjRmNDAiPgogICAgPHJlY3QgeD0iNSIgeT0iMyIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjgiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMyIgeT0iNSIgd2lkdGg9IjEwIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjYiIHdpZHRoPSIxMCIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIzIiB5PSI3IiB3aWR0aD0iMTAiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iOCIgd2lkdGg9IjgiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iOSIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iMTIiIHdpZHRoPSI2IiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjYiIHk9IjEzIiB3aWR0aD0iNCIgaGVpZ2h0PSIxIi8+CiAgPC9nPgoKICAKICA8ZyBmaWxsPSIjZmY3NzVmIj4KICAgIDxyZWN0IHg9IjEiIHk9IjYiIHdpZHRoPSIyIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjUiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEzIiB5PSI2IiB3aWR0aD0iMiIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMyIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTMiIHk9IjciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICA8L2c+CgogIAogIDxnIGZpbGw9IiMwODEwMTYiPgogICAgPHJlY3QgeD0iNiIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iOSIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogIDwvZz4KICA8ZyBmaWxsPSIjZjVmYmZmIj4KICAgIDxyZWN0IHg9IjYiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjkiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICA8L2c+Cjwvc3ZnPgoK) +![Ollama](https://img.shields.io/badge/Ollama-FFFFFF?style=flat-square&logo=Ollama&logoColor=black) + +📡 あなた専用のAI搭載ニュースレーダー。英語と中国語で日次ブリーフィングを生成します。 | 构建你专属的 AI 新闻雷达 + +[📖 ライブデモ](https://thysrael.github.io/Horizon/) · [📋 設定ガイド](https://thysrael.github.io/Horizon/configuration) · [English](README.md) · [简体中文](README_zh.md) + +
+ +## スクリーンショット + + + + + + +
+

ランク付けされた日次ブリーフィング

+Daily Overview +
+

背景・要約・ディスカッション

+News Detail +
+ +
+その他のスクリーンショット +
+ + + + + + +
+

ターミナル出力

+Terminal Output +
+

Feishu通知

+Feishu Notification +
+

メール配信

+Email Delivery +
+
+ +## なぜHorizonなのか? + +良いニュースは散らばっていて、悪いニュースは尽きることがありません。Horizonは、Hacker News、Reddit、Telegram、RSS、GitHubに対する個人的な一次フィルタを提供します。記事を取得・重複排除・スコアリング・フィルタリングし、背景情報やコミュニティでの議論を付加します。 + +しかしHorizonは単なる要約ツールではありません。AIはノイズを減らすのが得意ですが、ニュースには依然として人間の感性が必要です。信頼できる情報源、記事の読み方を変えるコメント、そして人だけが共有できる隠れた逸品です。Horizonは、カスタマイズ可能な情報源・しきい値・モデル・言語・配信チャネル・コメント要約・コミュニティ情報源ハブによって、その人間のレイヤーをループに組み込み続けます。 + +## 機能 + +- **📡 自分だけの情報源を監視** — Hacker News、RSS、Reddit、Telegram、Twitter/X、GitHubのリリースやユーザーアクティビティ、OpenBBの金融ニュースウォッチリストを1つのパイプラインで追跡 +- **🤖 ノイズを読むべきリストに変換** — Claude、GPT、Gemini、DeepSeek、Doubao、MiniMax、Ollama、またはOpenAI互換のあらゆるAPIで各記事を0〜10点でスコアリング +- **🔗 重複した記事を統合** — ブリーフィングに届く前に、プラットフォームをまたいで同じ記事を重複排除 +- **🔍 背景を理解する** — 馴染みのない概念・企業・プロジェクト・専門用語について、Webで調べた背景情報を付加 +- **💬 会話を読む** — Hacker News、Reddit、その他のサポート対象情報源からコミュニティのコメントを収集・要約 +- **🌐 2言語で公開** — 同じ情報源セットから英語と中国語の日次ブリーフィングを生成 +- **📝 日次サイトを公開** — 生成されたMarkdownをGitHub Pagesの日次ブリーフィングサイトとして公開 +- **📧 メールで配信** — 購読・購読解除を自動処理するセルフホストのSMTP/IMAPニュースレターを運用 +- **🔔 チャットや自動化へプッシュ** — テンプレート化された結果をFeishu/Lark、DingTalk、Slack、Discord、またはカスタムWebhookエンドポイントへ送信 +- **🧙 興味から始める** — セットアップウィザードを使ってパーソナライズされた情報源設定を生成 +- **⚙️ レーダーを調整** — 情報源・しきい値・モデル・言語・配信チャネルを1つのJSON設定からカスタマイズ + +## 仕組み + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "fontFamily": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + "fontSize": "18px", + "primaryTextColor": "#2d2a3e", + "primaryBorderColor": "#e0dbd3", + "lineColor": "#7c7891", + "tertiaryColor": "#faf8f5", + "clusterBkg": "#f3f0eb", + "clusterBorder": "#e0dbd3" + } +}}%% +flowchart LR + classDef config fill:#fbbf24,stroke:#d4a017,color:#2d2a3e,stroke-width:1.5px; + classDef source fill:#ede7fb,stroke:#6d4aaa,color:#2d2a3e,stroke-width:1.5px; + classDef process fill:#ffe8db,stroke:#e0652e,color:#2d2a3e,stroke-width:1.5px; + classDef output fill:#f9d7e5,stroke:#be185d,color:#2d2a3e,stroke-width:1.5px; + + config["⚙️ Config
sources, thresholds, models, outputs"] + + subgraph sources["Configured Sources"] + rss["📡 RSS"] + hn["📰 Hacker News"] + reddit["💬 Reddit"] + telegram["✈️ Telegram"] + twitter["🐦 Twitter / X"] + github["🐙 GitHub"] + openbb["💹 OpenBB"] + end + + fetch["📥 Fetch"] + dedup["🧹 Deduplicate"] + score["🤖 AI Score & Filter"] + enrich["🔎 Enrich"] + summary["📝 Summarize"] + + subgraph outputs["Outputs"] + direction TB + site["🌐 Pages"] + email["📧 Email"] + webhook["🔔 Webhooks"] + mcp["🧩 MCP"] + end + + config --> fetch + rss --> fetch + hn --> fetch + reddit --> fetch + telegram --> fetch + twitter --> fetch + github --> fetch + openbb --> fetch + + fetch --> dedup --> score --> enrich --> summary + config --> score + config --> summary + config --> outputs + + summary --> site + summary --> email + summary --> webhook + summary --> mcp + + class config config + class rss,hn,reddit,telegram,twitter,github,openbb source + class fetch,dedup,score,enrich,summary process + class site,email,webhook,mcp output +``` + +1. **定義(Define)** — 情報源・しきい値・モデル・言語・配信を1つのJSON設定で構成します。 +2. **取得(Fetch)** — 設定されたすべての情報源から最新コンテンツを並行して取得します。 +3. **重複排除(Deduplicate)** — プラットフォームをまたいで、同じ記事やURLを指す項目を統合します。 +4. **スコアリングとフィルタリング(Score & Filter)** — AIで項目をランク付けし、しきい値を超えるものだけを残します。 +5. **エンリッチ(Enrich)** — 重要な項目について、Webで背景情報を検索しコミュニティの議論を収集します。 +6. **要約(Summarize)** — 要約・タグ・参照を含む構造化されたMarkdownブリーフィングを生成します。 +7. **配信(Deliver)** — 結果をGitHub Pages、メール、Feishuなどのwebhook、MCP、またはローカルファイルへ公開します。 + +## クイックスタート + +### 1. インストール + +**オプションA: ローカルインストール** + +```bash +git clone https://github.com/Thysrael/Horizon.git +cd Horizon + +# uvでインストール(推奨) +uv sync + +# 必要に応じてテスト/開発用の追加依存をインストール +uv sync --extra dev + +# またはpipで +pip install -e . +``` + +`dev`は現在`pyproject.toml`でオプションのextraとして定義されているため、pytestやその他の開発用依存には`uv sync --extra dev`を使用してください。 + +オプションのOpenBB金融ニュース情報源が必要な場合は、そのextraもインストールしてください。 + +```bash +uv sync --extra openbb +``` + +`openbb`がお使いの環境でwheelのないパッケージを取得する場合は、バイナリのみでSDKを手動インストールしてください。 + +```bash +uv pip install --only-binary=:all: openbb openbb-benzinga +``` + +**オプションB: Docker** + +```bash +git clone https://github.com/Thysrael/Horizon.git +cd Horizon + +# 環境を設定 +cp .env.example .env +cp data/config.example.json data/config.json +# .env と data/config.json をAPIキーや好みに合わせて編集 + +# Docker Composeで実行 +docker compose run --rm horizon + +# またはカスタムの時間枠で実行 +docker compose run --rm horizon --hours 48 +``` + +### 2. 設定 + +**オプションA: 対話式ウィザード(推奨)** + +```bash +uv run horizon-wizard +``` + +ウィザードはあなたの興味(例: 「LLM inference」「嵌入式」「web security」)について質問し、`data/config.json`を自動生成します。 + +**オプションB: 手動設定** + +```bash +cp .env.example .env # APIキーを追加 +cp data/config.example.json data/config.json # 情報源をカスタマイズ +``` + +最小限の手動設定: + +```jsonc +{ + "ai": { + "provider": "openai", + "model": "gpt-4", + "api_key_env": "OPENAI_API_KEY" + }, + "sources": { + "rss": [ + { "name": "Simon Willison", "url": "https://simonwillison.net/atom/everything/" } + ] + }, + "filtering": { + "ai_score_threshold": 6.0 + } +} +``` + +**バランス調整されたダイジェスト(オプション)** + +最終的なダイジェストのサイズを制限し、1つのカテゴリが結果を支配しないようにします。カテゴリは`sources.rss[].category`などの情報源設定から取得されます。 + +```jsonc +{ + "filtering": { + "ai_score_threshold": 6.0, + "max_items": 20, + "category_groups": { + "ai": { + "limit": 5, + "categories": ["ai-news", "ai-tools", "machine-learning"] + }, + "finance": { + "limit": 5, + "categories": ["finance", "business", "equities"] + } + }, + "default_group": "other", + "default_group_limit": 3 + } +} +``` + +グループの上限は、AIスコアによるフィルタリングの後、エンリッチの前に適用されます。`category_groups`と`max_items`を省略した場合、フィルタリングは従来どおりに動作します。 + +`api_key_env`はAPIキーそのものではなく、環境変数の名前でなければなりません。実際のシークレットは`.env`に記述してください。 + +```bash +OPENAI_API_KEY=sk-your-key +``` + +Geminiの場合は`GOOGLE_API_KEY`を使用します。 + +```jsonc +{ + "ai": { + "provider": "gemini", + "model": "gemini-2.0-flash", + "api_key_env": "GOOGLE_API_KEY" + } +} +``` + +`data/config.json`内の任意の文字列値は、`${VAR_NAME}`で環境変数を参照できます。これは`ai.base_url`、非公開のRSSフィードURL、webhookエンドポイント、カスタムヘッダーテンプレートなどの値に便利です。 + +完全なリファレンスについては、[設定ガイド](docs/configuration.md)を参照してください。 + +### 3. 実行 + +#### ローカルインストール + +```bash +uv run horizon # デフォルトの24時間枠で実行 +uv run horizon --hours 48 # 過去48時間から取得 +``` + +#### Dockerで + +```bash +docker compose run --rm horizon # デフォルトの24時間枠で実行 +docker compose run --rm horizon --hours 48 # 過去48時間から取得 +``` + +生成されたレポートは`data/summaries/`に保存されます。 + +### 4. 自動化(オプション) + +Horizonは**GitHub Actions**のcronジョブとして最適に動作します。日次ブリーフィングを生成しGitHub Pagesへ自動デプロイする、すぐに使えるワークフローについては[`.github/workflows/daily-summary.yml`](.github/workflows/daily-summary.yml)を参照してください。 + +## サポートされている情報源 + +| 情報源 | 取得する内容 | コメント | +|--------|----------------|----------| +| **Hacker News** | スコア順のトップ記事 | あり(上位N件のコメント) | +| **RSS / Atom** | 任意のRSSまたはAtomフィード | — | +| **Reddit** | サブレディット + ユーザー投稿 | あり(上位N件のコメント) | +| **Telegram** | 公開チャンネルのメッセージ | — | +| **Twitter / X** | 特定ユーザーのツイート | あり(上位N件の返信) | +| **GitHub** | ユーザーイベント & リポジトリのリリース | — | +| **OpenBB** | ウォッチリスト/プロバイダー別の企業金融ニュース | — | + +## ブリーフィングの届け先 + +Horizonは、生成されたブリーフィングをいくつかの方法で公開・配信できます。 + +| チャネル | 内容 | +|---------|--------------| +| **GitHub Pages 日次サイト** | 生成されたMarkdownを`docs/`にコピーし、GitHub Pagesが毎日更新されるブリーフィングサイトを公開できるようにします | +| **メール購読** | 日次ブリーフィングを購読者に送信し、SMTP/IMAPを通じて購読・購読解除リクエストを処理します | +| **Webhook通知** | 成功または失敗の結果をFeishu/Lark、DingTalk、Slack、Discord、または任意のカスタムWebhookエンドポイントへプッシュします | +| **MCPサーバー** | Horizonのパイプラインステップをツールとして公開し、AIアシスタントが取得・スコアリング・フィルタリング・エンリッチ・要約・ワークフロー全体の実行を行えるようにします | + +セットアップの詳細については、[設定ガイド](docs/configuration.md)を参照してください。MCPツールのリファレンスとクライアントのセットアップについては、[`src/mcp/README.md`](src/mcp/README.md)と[`src/mcp/integration.md`](src/mcp/integration.md)を参照してください。 + +## サポーター + +Horizonは余暇に運営されているオープンソースプロジェクトです。プロジェクトを支援したい、またはここに掲載されたい場合は、お気軽に[issueを開く](https://github.com/Thysrael/Horizon/issues/new)か[メールでご連絡ください](mailto:thysrael@163.com)。 + +| サポーター | 詳細 | +|-----------|---------| +| [Compshare / 优云智算](https://www.compshare.cn/?ytag=GPU_YY_git_Horizon) | Compshareは現在Horizonをサポートしています。CompshareはUCloudのAIクラウドプラットフォームで、月額49人民元から始まるコスト効率の良い月額・従量課金の国内モデルエージェントプランや、安定した公式リレーの海外モデルを提供しています。Claude Code、Codex、APIの利用に対応し、エンタープライズグレードの高同時実行、24時間365日の技術サポート、セルフサービスの請求書発行を備えています。

彼らの[リンク](https://www.compshare.cn/?ytag=GPU_YY_git_Horizon)から登録すると、無料で5人民元のトライアルクレジットを受け取れます。 | + +## ドキュメント + +| ガイド | 説明 | +|-------|-------------| +| [設定](docs/configuration.md) | AIプロバイダー、情報源、フィルタリング、メール、webhook、GitHub Pages、MCPのセットアップ | +| [スコアリング](docs/scoring.md) | Horizonがニュース項目を評価・ランク付けする方法 | +| [スクレイパー](docs/scrapers.md) | 情報源スクレイパーの詳細と拡張に関する注記 | +| [コンテンツエクストラクター](docs/extractors.md) | RSS情報源の全文抽出 | +| [MCPツール](src/mcp/README.md) | MCP互換クライアント向けのツールリファレンス | + +## プロジェクトの状況 + +Horizonはすでに日次ブリーフィングの全ループをサポートしています。マルチソース収集、AIスコアリング、重複排除、エンリッチ、コメント要約、2言語生成、GitHub Pages公開、メール配信、webhook配信、Dockerデプロイ、MCP統合、セットアップウィザードです。 + +予定している改善: + +- Discordなど、より多くの情報源タイプ +- 情報源ごとのカスタムスコアリングプロンプト +- GitHubでのリリース公開 +- `pip install`用にPyPIへパッケージを公開 + +## コントリビューション + +コントリビューションを歓迎します。コード、ドキュメント、情報源共有のガイドラインについては[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。 + +### 情報源を共有する + +価値のある情報源の発見をHorizonコミュニティと共有したいですか? ぜひ**[horizon1123.top](https://horizon1123.top)**から投稿してください。 + +## 謝辞 + +- プロモーションの場を提供してくださった[LINUX.DO](https://linux.do/)に特別な感謝を。 +- 貴重な指導と提案をいただいた[HelloGitHub](https://hellogithub.com/)に特別な感謝を。 +- 小紅書(XiaoHongShu)でのプロモーションをしてくださった[AIGC Link](https://xhslink.com/m/80ngts127cA)に特別な感謝を。 + +## ライセンス + +[MIT](LICENSE) diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..e303485 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,388 @@ +
+ +

🌅 Horizon

+ +

你只需享受新闻,剩下交给 Horizon。

+ +Thysrael%2FHorizon | Trendshift +Thysrael%2FHorizon | Trendshift +Featured|HelloGitHub +
+ +[![License](https://img.shields.io/badge/license-MIT-green.svg?style=for-the-badge&logo=opensourceinitiative&logoColor=white)](LICENSE) +[![Tool uv](https://img.shields.io/badge/Tool-uv-4B275F?style=for-the-badge&logo=uv&logoColor=white)](https://github.com/astral-sh/uv) +[![Website](https://img.shields.io/badge/Website-Horizon-263238?style=for-the-badge&logo=homepage&logoColor=white)](https://www.horizon1123.top/) +[![Daily](https://img.shields.io/github/actions/workflow/status/Thysrael/Horizon/deploy-docs.yml?branch=main&label=Daily&style=for-the-badge&logo=date-fns&logoColor=white)](https://thysrael.github.io/Horizon/) +[![Commit](https://img.shields.io/github/commit-activity/m/Thysrael/Horizon?label=Commit&style=for-the-badge&logo=github&logoColor=white)](https://github.com/Thysrael/Horizon/commits/main) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/Thysrael/Horizon/pulls) +![Sources Welcome](https://img.shields.io/badge/sources-welcome-f97316?style=for-the-badge&logo=rss&logoColor=white) + +![Claude](https://img.shields.io/badge/Claude-f0daba?style=flat-square&logo=anthropic&logoColor=black) +![GPT](https://img.shields.io/badge/GPT-10A37F?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBmaWxsPSJ3aGl0ZSIgZD0iTTIyLjI4MTkgOS44MjExYTUuOTg0NyA1Ljk4NDcgMCAwIDAtLjUxNTctNC45MTA4IDYuMDQ2MiA2LjA0NjIgMCAwIDAtNi41MDk4LTIuOUE2LjA2NTEgNi4wNjUxIDAgMCAwIDQuOTgwNyA0LjE4MThhNS45ODQ3IDUuOTg0NyAwIDAgMC0zLjk5NzcgMi45IDYuMDQ2MiA2LjA0NjIgMCAwIDAgLjc0MjcgNy4wOTY2IDUuOTggNS45OCAwIDAgMCAuNTExIDQuOTEwNyA2LjA1MSA2LjA1MSAwIDAgMCA2LjUxNDYgMi45MDAxQTUuOTg0NyA1Ljk4NDcgMCAwIDAgMTMuMjU5OSAyNGE2LjA1NTcgNi4wNTU3IDAgMCAwIDUuNzcxOC00LjIwNTggNS45ODk0IDUuOTg5NCAwIDAgMCAzLjk5NzctMi45MDAxIDYuMDU1NyA2LjA1NTcgMCAwIDAtLjc0NzUtNy4wNzI5em0tOS4wMjIgMTIuNjA4MWE0LjQ3NTUgNC40NzU1IDAgMCAxLTIuODc2NC0xLjA0MDhsLjE0MTktLjA4MDQgNC43NzgzLTIuNzU4MmEuNzk0OC43OTQ4IDAgMCAwIC4zOTI3LS42ODEzdi02LjczNjlsMi4wMiAxLjE2ODZhLjA3MS4wNzEgMCAwIDEgLjAzOC4wNTJ2NS41ODI2YTQuNTA0IDQuNTA0IDAgMCAxLTQuNDk0NSA0LjQ5NDR6bS05LjY2MDctNC4xMjU0YTQuNDcwOCA0LjQ3MDggMCAwIDEtLjUzNDYtMy4wMTM3bC4xNDIuMDg1MiA0Ljc4MyAyLjc1ODJhLjc3MTIuNzcxMiAwIDAgMCAuNzgwNiAwbDUuODQyOC0zLjM2ODV2Mi4zMzI0YS4wODA0LjA4MDQgMCAwIDEtLjAzMzIuMDYxNUw5Ljc0IDE5Ljk1MDJhNC40OTkyIDQuNDk5MiAwIDAgMS02LjE0MDgtMS42NDY0ek0yLjM0MDggNy44OTU2YTQuNDg1IDQuNDg1IDAgMCAxIDIuMzY1NS0xLjk3MjhWMTEuNmEuNzY2NC43NjY0IDAgMCAwIC4zODc5LjY3NjVsNS44MTQ0IDMuMzU0My0yLjAyMDEgMS4xNjg1YS4wNzU3LjA3NTcgMCAwIDEtLjA3MSAwbC00LjgzMDMtMi43ODY1QTQuNTA0IDQuNTA0IDAgMCAxIDIuMzQwOCA3Ljg3MnptMTYuNTk2MyAzLjg1NThMMTMuMTAzOCA4LjM2NCAxNS4xMTkyIDcuMmEuMDc1Ny4wNzU3IDAgMCAxIC4wNzEgMGw0LjgzMDMgMi43OTEzYTQuNDk0NCA0LjQ5NDQgMCAwIDEtLjY3NjUgOC4xMDQydi01LjY3NzJhLjc5Ljc5IDAgMCAwLS40MDctLjY2N3ptMi4wMTA3LTMuMDIzMWwtLjE0Mi0uMDg1Mi00Ljc3MzUtMi43ODE4YS43NzU5Ljc3NTkgMCAwIDAtLjc4NTQgMEw5LjQwOSA5LjIyOTdWNi44OTc0YS4wNjYyLjA2NjIgMCAwIDEgLjAyODQtLjA2MTVsNC44MzAzLTIuNzg2NmE0LjQ5OTIgNC40OTkyIDAgMCAxIDYuNjgwMiA0LjY2ek04LjMwNjUgMTIuODYzbC0yLjAyLTEuMTYzOGEuMDgwNC4wODA0IDAgMCAxLS4wMzgtLjA1NjdWNi4wNzQyYTQuNDk5MiA0LjQ5OTIgMCAwIDEgNy4zNzU3LTMuNDUzN2wtLjE0Mi4wODA1TDguNzA0IDUuNDU5YS43OTQ4Ljc5NDggMCAwIDAtLjM5MjcuNjgxM3ptMS4wOTc2LTIuMzY1NGwyLjYwMi0xLjQ5OTggMi42MDY5IDEuNDk5OHYyLjk5OTRsLTIuNTk3NCAxLjQ5OTctMi42MDY3LTEuNDk5N1oiLz48L3N2Zz4=) +![Gemini](https://img.shields.io/badge/Gemini-8E75B2?style=flat-square&logo=googlegemini&logoColor=white) +![DeepSeek](https://img.shields.io/badge/DeepSeek-0A6DC2?style=flat-square&logo=deepseek&logoColor=white) +![Doubao](https://img.shields.io/badge/Doubao-00D6C2?style=flat-square&logo=bytedance&logoColor=white) +![MiniMax](https://img.shields.io/badge/MiniMax-FF6F00?style=flat-square&logo=minimax&logoColor=white) +![OpenClaw](https://img.shields.io/badge/OpenClaw-C83232?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDE2IDE2IiBhcmlhLWxhYmVsPSJQaXhlbCBsb2JzdGVyIj4KICA8cmVjdCB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiLz4KICAKICA8ZyBmaWxsPSIjM2EwYTBkIj4KICAgIDxyZWN0IHg9IjEiIHk9IjUiIHdpZHRoPSIxIiBoZWlnaHQ9IjMiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjgiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjkiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjQiIHk9IjIiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjQiIHk9IjEwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSI1IiB5PSIyIiB3aWR0aD0iNiIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMSIgeT0iMiIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTIiIHk9IjMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEyIiB5PSI5IiB3aWR0aD0iMSIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMyIgeT0iNCIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTMiIHk9IjgiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI1IiB3aWR0aD0iMSIgaGVpZ2h0PSIzIi8+CiAgICA8cmVjdCB4PSI1IiB5PSIxMSIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iMTIiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjExIiB5PSIxMiIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMyIgeT0iMTMiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEyIiB5PSIxMyIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iMTQiIHdpZHRoPSI2IiBoZWlnaHQ9IjEiLz4KICA8L2c+CgogIAogIDxnIGZpbGw9IiNmZjRmNDAiPgogICAgPHJlY3QgeD0iNSIgeT0iMyIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjgiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMyIgeT0iNSIgd2lkdGg9IjEwIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjMiIHk9IjYiIHdpZHRoPSIxMCIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIzIiB5PSI3IiB3aWR0aD0iMTAiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNCIgeT0iOCIgd2lkdGg9IjgiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iOSIgd2lkdGg9IjYiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iNSIgeT0iMTIiIHdpZHRoPSI2IiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjYiIHk9IjEzIiB3aWR0aD0iNCIgaGVpZ2h0PSIxIi8+CiAgPC9nPgoKICAKICA8ZyBmaWxsPSIjZmY3NzVmIj4KICAgIDxyZWN0IHg9IjEiIHk9IjYiIHdpZHRoPSIyIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjUiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjIiIHk9IjciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjEzIiB5PSI2IiB3aWR0aD0iMiIgaGVpZ2h0PSIxIi8+CiAgICA8cmVjdCB4PSIxMyIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iMTMiIHk9IjciIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICA8L2c+CgogIAogIDxnIGZpbGw9IiMwODEwMTYiPgogICAgPHJlY3QgeD0iNiIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogICAgPHJlY3QgeD0iOSIgeT0iNSIgd2lkdGg9IjEiIGhlaWdodD0iMSIvPgogIDwvZz4KICA8ZyBmaWxsPSIjZjVmYmZmIj4KICAgIDxyZWN0IHg9IjYiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICAgIDxyZWN0IHg9IjkiIHk9IjQiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz4KICA8L2c+Cjwvc3ZnPgoK) +![Ollama](https://img.shields.io/badge/Ollama-FFFFFF?style=flat-square&logo=Ollama&logoColor=black) + +📡 构建你专属的 AI 新闻雷达,生成中英双语日报。 | Your own AI-powered news radar. + +[📖 在线演示](https://thysrael.github.io/Horizon/) · [📋 配置指南](https://thysrael.github.io/Horizon/configuration) · [English](README.md) · [日本語](README_ja.md) + +
+ +## 截图 + + + + + + +
+

按优先级排序的日报

+日报总览 +
+

背景、总结与评论

+新闻详情 +
+ +
+More Screenshots +
+ + + + + + +
+

终端输出

+终端输出 +
+

飞书通知

+飞书通知 +
+

邮件推送

+邮件推送 +
+
+ +## 为什么需要 Horizon? + +好新闻分散在各处,坏信息却源源不断。Horizon 为你先完成第一轮筛选:从 Hacker News、Reddit、Telegram、RSS、Twitter/X、GitHub 和 OpenBB 抓取内容,合并重复新闻,用 AI 打分过滤,并为重要内容补充背景解释和社区讨论。 + +但 Horizon 不只是又一个摘要工具。AI 很擅长降低噪声,但新闻仍然需要人的品味:你信任哪些信息源,哪些评论改变了你对事件的理解,哪些小众来源值得被更多人看见。Horizon 通过可定制的信息源、筛选标准、模型、语言、分发方式、评论摘要和社区信息源官网,把这层“人味”保留下来。 + +## 功能特性 + +- **📡 关注你的信息源** — 将 Hacker News、RSS、Reddit、Telegram、Twitter/X、GitHub Release / 用户动态,以及 OpenBB 金融新闻观察列表纳入同一条 pipeline +- **🤖 把噪声变成阅读清单** — 使用 Claude、GPT、Gemini、DeepSeek、豆包、MiniMax 或任意 OpenAI 兼容 API,为每条内容评分 0-10 +- **🔗 合并重复新闻** — 在生成日报前自动合并来自不同平台的相同故事 +- **🔍 补全背景知识** — 为陌生概念、公司、项目和技术术语补充网络搜索得到的背景解释 +- **💬 读到社区声音** — 收集并总结 Hacker News、Reddit 等来源的评论讨论 +- **🌐 生成双语日报** — 基于同一组信息源生成英文和中文日报 +- **📝 发布日报站点** — 将生成的 Markdown 发布为 GitHub Pages 静态日报站点 +- **📧 邮件分发** — 运行自托管 SMTP/IMAP 邮件列表,自动处理订阅与退订 +- **🔔 推送到聊天和自动化工具** — 将模板化结果发送到飞书、钉钉、Slack、Discord 或自定义 Webhook +- **🧙 从兴趣开始配置** — 通过交互式向导根据你的兴趣生成个性化信息源配置 +- **⚙️ 调校你的新闻雷达** — 在单个 JSON 配置中定制信息源、阈值、模型、语言和分发方式 + +## 工作原理 + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "fontFamily": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + "fontSize": "18px", + "primaryTextColor": "#2d2a3e", + "primaryBorderColor": "#e0dbd3", + "lineColor": "#7c7891", + "tertiaryColor": "#faf8f5", + "clusterBkg": "#f3f0eb", + "clusterBorder": "#e0dbd3" + } +}}%% +flowchart LR + classDef config fill:#fbbf24,stroke:#d4a017,color:#2d2a3e,stroke-width:1.5px; + classDef source fill:#ede7fb,stroke:#6d4aaa,color:#2d2a3e,stroke-width:1.5px; + classDef process fill:#ffe8db,stroke:#e0652e,color:#2d2a3e,stroke-width:1.5px; + classDef output fill:#f9d7e5,stroke:#be185d,color:#2d2a3e,stroke-width:1.5px; + + config["⚙️ 配置
信息源、阈值、模型、输出方式"] + + subgraph sources["已配置的信息源"] + rss["📡 RSS"] + hn["📰 Hacker News"] + reddit["💬 Reddit"] + telegram["✈️ Telegram"] + twitter["🐦 Twitter / X"] + github["🐙 GitHub"] + openbb["💹 OpenBB"] + end + + fetch["📥 抓取"] + dedup["🧹 新闻去重"] + score["🤖 AI 打分与过滤"] + enrich["🔎 内容丰富"] + summary["📝 总结生成"] + + subgraph outputs["输出形式"] + direction TB + site["🌐 Pages"] + email["📧 邮件"] + webhook["🔔 Webhook"] + mcp["🧩 MCP"] + end + + config --> fetch + rss --> fetch + hn --> fetch + reddit --> fetch + telegram --> fetch + twitter --> fetch + github --> fetch + openbb --> fetch + + fetch --> dedup --> score --> enrich --> summary + config --> score + config --> summary + config --> outputs + + summary --> site + summary --> email + summary --> webhook + summary --> mcp + + class config config + class rss,hn,reddit,telegram,twitter,github,openbb source + class fetch,dedup,score,enrich,summary process + class site,email,webhook,mcp output +``` + +1. **定义** — 用一个 JSON 配置好信息源、阈值、模型、语言和分发方式。 +2. **抓取** — 并发拉取所有已配置信息源的最新内容。 +3. **去重** — 合并来自不同平台、指向同一故事或 URL 的内容。 +4. **打分与过滤** — 用 AI 对内容排序,只保留超过阈值的条目。 +5. **丰富** — 为重要内容补充搜索得到的背景信息和社区讨论。 +6. **总结** — 生成结构化的 Markdown 日报,包含摘要、标签和参考链接。 +7. **分发** — 将结果发布到 GitHub Pages、邮件、飞书等 webhook、MCP 或本地文件。 + +## 赞助 + +Horizon 是一个业余时间维护的开源项目。如果你愿意支持这个项目,或希望出现在这里,欢迎[创建一个 Issue](https://github.com/Thysrael/Horizon/issues/new) 或[发邮件](mailto:thysrael@163.com)联系我。 + +| 支持方 | 说明 | +|--------|------| +| [Compshare / 优云智算](https://www.compshare.cn/?ytag=GPU_YY_git_Horizon) | 优云智算目前正在支持 Horizon。优云智算是 UCloud 旗下 AI 云平台,主打包月、按次的高性价比国模 Agent Plan 套餐,低至 49 元/月起,同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用,支持企业高并发、7*24 技术支持和自助开票。

通过其[链接](https://www.compshare.cn/?ytag=GPU_YY_git_Horizon)注册,可获得 5 元平台体验金。 | + +## 快速开始 + +### 1. 安装 + +#### 方式 A:本地安装 + +```bash +git clone https://github.com/Thysrael/Horizon.git +cd horizon + +# 使用 uv 安装(推荐) +uv sync + +# 需要测试/开发依赖时 +uv sync --extra dev + +# 或使用 pip +pip install -e . +``` + +当前 `dev` 在 `pyproject.toml` 中定义为 optional extra,因此安装 `pytest` 等开发依赖时应使用 `uv sync --extra dev`。 + +如果你要启用可选的 OpenBB 金融新闻源,还需要安装对应 extra: + +```bash +uv sync --extra openbb +``` + +如果 `openbb` 在你的机器上会拉到缺少 wheel 的依赖,建议改用只安装二进制包: + +```bash +uv pip install --only-binary=:all: openbb openbb-benzinga +``` + +#### 方式 B:Docker + +```bash +git clone https://github.com/Thysrael/Horizon.git +cd horizon + +# 配置环境 +cp .env.example .env +cp data/config.example.json data/config.json +# 编辑 .env 和 data/config.json,填入你的 API 密钥和偏好设置 + +# 使用 Docker Compose 运行 +docker compose run --rm horizon + +# 或自定义时间窗口 +docker compose run --rm horizon --hours 48 +``` + +### 2. 配置 + +**方式 A:交互式向导(推荐)** + +```bash +uv run horizon-wizard +``` + +向导会询问你的兴趣(如"LLM 推理"、"嵌入式"、"web 安全"),自动推荐并生成 `data/config.json`,还可选让 AI 补充推荐小众源。若你想分享信息源,请前往 [horizon1123.top](https://horizon1123.top/)。 + +**方式 B:手动配置** + +```bash +cp .env.example .env # 添加 API 密钥 +cp data/config.example.json data/config.json # 自定义信息源 +``` + +最小手动配置示例: + +```jsonc +{ + "ai": { + "provider": "openai", + "model": "gpt-4", + "api_key_env": "OPENAI_API_KEY" + }, + "sources": { + "rss": [ + { "name": "Simon Willison", "url": "https://simonwillison.net/atom/everything/" } + ] + }, + "filtering": { + "ai_score_threshold": 6.0 + } +} +``` + +**均衡日报(可选)** + +可以限制日报总条数,并避免单一类别占据过多内容。类别来自 +`sources.rss[].category` 等信息源配置。 + +```jsonc +{ + "filtering": { + "ai_score_threshold": 6.0, + "max_items": 20, + "category_groups": { + "ai": { + "limit": 5, + "categories": ["ai-news", "ai-tools", "machine-learning"] + }, + "finance": { + "limit": 5, + "categories": ["finance", "business", "equities"] + } + }, + "default_group": "other", + "default_group_limit": 3 + } +} +``` + +分组限额在 AI 分数过滤之后、内容补充之前执行。未配置 +`category_groups` 和 `max_items` 时,筛选行为保持不变。 + +`data/config.json` 里的任意字符串值都可以通过 `${VAR_NAME}` 引用环境变量。这适合用于 `ai.base_url`、私有 RSS 链接、Webhook 地址或自定义请求头模板等字段。 + +完整配置参考请查看[配置指南](docs/configuration.md)。 + +### 3. 运行 + +#### 本地安装 + +```bash +uv run horizon # 使用默认 24 小时窗口 +uv run horizon --hours 48 # 抓取最近 48 小时的内容 +``` + +#### 使用 Docker + +```bash +docker compose run --rm horizon # 使用默认 24 小时窗口 +docker compose run --rm horizon --hours 48 # 抓取最近 48 小时的内容 +``` + +生成的日报将保存在 `data/summaries/` 目录中。 + +### 4. 自动化(可选) + +Horizon 非常适合作为 **GitHub Actions** 定时任务运行。查看 [`.github/workflows/daily-summary.yml`](.github/workflows/daily-summary.yml) 获取现成的工作流配置,可自动生成日报并部署到 GitHub Pages。 + +## 支持的信息源 + +| 信息源 | 抓取内容 | 评论收集 | +|--------|---------|---------| +| **Hacker News** | 按分数排序的热门文章 | 支持(前 N 条评论) | +| **RSS / Atom** | 任意 RSS 或 Atom 订阅源 | — | +| **Reddit** | Subreddit 帖子 + 用户动态 | 支持(前 N 条评论) | +| **Telegram** | 公开频道消息 | — | +| **Twitter / X** | 特定用户的推文 | 支持(前 N 条回复) | +| **GitHub** | 用户动态 & 仓库 Release | — | +| **OpenBB** | 按观察列表 / provider 抓取金融公司新闻 | — | + +## 日报可以去哪里 + +Horizon 支持通过多种方式发布和分发生成的日报: + +| 方式 | 作用 | +|------|------| +| **GitHub Pages 日报站点** | 将生成的 Markdown 复制到 `docs/`,通过 GitHub Pages 发布为每日更新的静态日报站点 | +| **邮件订阅** | 通过 SMTP/IMAP 向订阅者发送日报,并自动处理订阅/退订请求 | +| **Webhook 通知** | 在成功或失败时将结果推送到飞书、钉钉、Slack、Discord 或任意 Webhook 端点 | +| **MCP Server** | 将抓取、打分、过滤、富化、摘要和完整 pipeline 暴露为工具,供 AI 助手调用 | + +具体配置见[配置指南](docs/configuration.md)。MCP 工具说明和客户端接入见 [`src/mcp/README.md`](src/mcp/README.md) 与 [`src/mcp/integration.md`](src/mcp/integration.md)。 + +## 文档 + +| 文档 | 内容 | +|------|------| +| [配置指南](docs/configuration.md) | AI 模型、信息源、过滤、邮件、Webhook、GitHub Pages 和 MCP 配置 | +| [评分机制](docs/scoring.md) | Horizon 如何评估和排序新闻 | +| [抓取器](docs/scrapers.md) | 信息源抓取器说明和扩展细节 | +| [内容提取器](docs/extractors.md) | RSS 信息源的全文提取 | +| [MCP 工具](src/mcp/README.md) | MCP 客户端可调用的工具说明 | + +## 项目状态 + +Horizon 已经支持完整的日报流程:多源抓取、AI 打分、去重、背景补充、评论摘要、双语生成、GitHub Pages 发布、邮件分发、Webhook 推送、Docker 部署、MCP 集成和配置向导。 + +计划中的改进: + +- 更多信息源类型,例如 Discord +- 按信息源自定义打分 Prompt +- 在 GitHub 上发布 Release +- 发布到 PyPI,支持通过 `pip install` 安装 + +## 贡献 + +欢迎贡献!请随时提交 Issue 或 Pull Request。 + +### 分享信息源 + +想把有价值的信息源分享给 Horizon 社区?请直接前往 **[horizon1123.top](https://horizon1123.top)** 提交。 + +欢迎提交:你所在领域里优质的小众 RSS 发现、活跃 subreddit 的趋势、值得关注的 GitHub 动态,或 Telegram 频道精选内容。 + +## 鸣谢 + +- 特别感谢 [LINUX.DO](https://linux.do/) 提供的宣传平台。 +- 特别感谢 [HelloGitHub](https://hellogithub.com/) 提供的指导意见。 +- 特别感谢 [AIGC Link](https://xhslink.com/m/80ngts127cA) 提供的小红书和微信公众号宣传。 + +## 许可证 + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..26d8e2e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in Horizon, please do **not** open a public issue. +Instead, please report it privately by email: +**thysrael@gmail.com** + +Please include: +- A clear description of the issue +- Steps to reproduce the problem +- The affected version, commit, or environment +- Any proof-of-concept, screenshots, or logs that may help + +## Response Process + +I will try to: + +- Acknowledge receipt within **7 days** +- Investigate and validate the report +- Work on a fix and coordinate responsible disclosure when appropriate + +## Supported Versions + +Security updates are provided for the latest maintained version of Horizon. + +## Disclosure Policy + +Please avoid public disclosure until I have had a reasonable opportunity to investigate and release a fix. \ No newline at end of file diff --git a/data/config.example.json b/data/config.example.json new file mode 100644 index 0000000..26e5bc3 --- /dev/null +++ b/data/config.example.json @@ -0,0 +1,181 @@ +{ + "version": "1.0", + "ai": { + "provider": "openai", + "model": "gpt-4", + "api_key_env": "OPENAI_API_KEY", + "temperature": 0.3, + "max_tokens": 4096, + "throttle_sec": 0, + "analysis_concurrency": 1, + "enrichment_concurrency": 1 + }, + "email": { + "enabled": false, + "smtp_server": "smtp.qq.com", + "smtp_port": 465, + "imap_server": "imap.qq.com", + "imap_port": 993, + "email_address": "xxx@qq.com", + "sender_name": "Horizon Daily", + "subscribe_keyword": "SUBSCRIBE", + "unsubscribe_keyword": "UNSUBSCRIBE" + }, + "sources": { + "github": [ + { + "type": "user_events", + "username": "torvalds", + "enabled": true + }, + { + "type": "repo_releases", + "owner": "astral-sh", + "repo": "uv", + "enabled": true + } + ], + "hackernews": { + "enabled": true, + "fetch_top_stories": 20, + "min_score": 100 + }, + "rss": [ + { + "name": "Simon Willison", + "url": "https://simonwillison.net/atom/everything/", + "enabled": true, + "category": "ai-tools" + }, + { + "name": "LWN.net (subscriber full-text)", + "url": "https://lwn.net/headlines/full_text?key=${LWN_KEY}", + "enabled": false, + "category": "linux" + } + ], + "reddit": { + "enabled": true, + "subreddits": [ + { + "subreddit": "MachineLearning", + "enabled": true, + "sort": "hot", + "time_filter": "day", + "fetch_limit": 15, + "min_score": 50 + } + ], + "users": [ + { + "username": "iamthatis", + "enabled": true, + "sort": "new", + "fetch_limit": 10 + } + ], + "fetch_comments": 5 + }, + "twitter": { + "enabled": false, + "users": ["karpathy", "ylecun"], + "fetch_limit": 10, + "fetch_reply_text": false, + "max_replies_per_tweet": 3, + "max_tweets_to_expand": 10, + "reply_min_likes": 5 + }, + "openbb": { + "enabled": false, + "fetch_filings": false, + "filings_provider": "sec", + "watchlists": [ + { + "name": "megacaps", + "enabled": true, + "provider": "yfinance", + "fetch_limit": 20, + "category": "equities", + "symbols": ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META", "TSLA"] + } + ] + }, + "ossinsight": { + "enabled": false, + "period": "past_24_hours", + "languages": ["All", "Python", "TypeScript"], + "keywords": [], + "min_stars": 10, + "max_items": 30 + }, + "gdelt": { + "enabled": false, + "query": "artificial intelligence", + "mode": "ArtList", + "max_records": 75, + "timespan": null, + "language": null, + "country": null, + "category": "news" + }, + "google_news": { + "enabled": false, + "query": "artificial intelligence", + "language": "en", + "country": "US", + "ceid": null, + "max_results": 100, + "category": "news" + } + }, + "filtering": { + "ai_score_threshold": 6.0, + "time_window_hours": 24, + "max_items": null, + "category_groups": {}, + "default_group": "other", + "default_group_limit": null + }, + "webhook": { + "enabled": false, + "url_env": "HORIZON_WEBHOOK_URL", + "delivery": "summary", + "overview_position": "first", + "platform": "generic", + "layout": "markdown", + "fallback_layout": "markdown", + "languages": null, + "request_body": { + "msg_type": "interactive", + "card": { + "schema": "2.0", + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "#{message_title}" + }, + "template": "blue" + }, + "body": { + "elements": [ + { + "tag": "markdown", + "content": "Horizon生成结果: #{result}\nHorizon日报重要资讯数量: #{important_items}/#{all_items}" + }, + { + "tag": "hr" + }, + { + "tag": "markdown", + "content": "#{summary}" + } + ] + } + } + }, + "headers": "" + } +} diff --git a/data/config.github.json b/data/config.github.json new file mode 100644 index 0000000..c2037a2 --- /dev/null +++ b/data/config.github.json @@ -0,0 +1,150 @@ +{ + "version": "1.0", + "ai": { + "provider": "deepseek", + "model": "deepseek-v4-flash", + "base_url": "https://api.deepseek.com", + "api_key_env": "DEEPSEEK_API_KEY", + "temperature": 0.3, + "max_tokens": 8192, + "throttle_sec": 1, + "languages": [ + "zh", + "en" + ], + "analysis_concurrency": 5, + "enrichment_concurrency": 5 + }, + "sources": { + "github": [ + { + "type": "user_events", + "username": "karpathy", + "enabled": true + }, + { + "type": "repo_releases", + "owner": "vllm-project", + "repo": "vllm", + "enabled": true + }, + { + "type": "repo_releases", + "owner": "sgl-project", + "repo": "sglang", + "enabled": true + }, + { + "type": "repo_releases", + "owner": "triton-lang", + "repo": "triton", + "enabled": true + } + ], + "hackernews": { + "enabled": true, + "fetch_top_stories": 30, + "min_score": 150 + }, + "rss": [ + { + "name": "Simon Willison", + "url": "https://simonwillison.net/atom/everything/", + "enabled": true, + "category": "ai-tools" + }, + { + "name": "GitHub Trending - Daily", + "url": "https://mshibanami.github.io/GitHubTrendingRSS/daily/all.xml", + "enabled": true, + "category": "github-trending" + }, + { + "name": "Semianalysis", + "url": "https://newsletter.semianalysis.com/feed", + "enabled": true, + "category": "semiconductors" + }, + { + "name": "LWN.net", + "url": "https://lwn.net/headlines/full_text?key=${LWN_KEY}", + "enabled": true, + "category": "linux-kernel" + } + ], + "reddit": { + "enabled": true, + "subreddits": [ + { + "subreddit": "MachineLearning", + "enabled": true, + "sort": "hot", + "time_filter": "day", + "fetch_limit": 10, + "min_score": 60 + }, + { + "subreddit": "LocalLLaMA", + "enabled": true, + "sort": "hot", + "time_filter": "day", + "fetch_limit": 10, + "min_score": 60 + } + ], + "users": [], + "fetch_comments": 10 + }, + "telegram": { + "enabled": true, + "channels": [ + { + "channel": "zaihuapd", + "enabled": true, + "fetch_limit": 20 + } + ] + } + }, + "filtering": { + "ai_score_threshold": 8.0, + "time_window_hours": 24 + }, + "webhook": { + "enabled": true, + "url_env": "HORIZON_WEBHOOK_URL", + "delivery": "summary_and_items", + "overview_position": "last", + "platform": "feishu", + "layout": "collapsible", + "fallback_layout": "markdown", + "languages": [ + "zh" + ], + "request_body": { + "msg_type": "interactive", + "card": { + "schema": "2.0", + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "#{message_title}" + }, + "template": "blue" + }, + "body": { + "elements": [ + { + "tag": "markdown", + "content": "#{summary}" + } + ] + } + } + }, + "headers": "" + } +} \ No newline at end of file diff --git a/data/presets.json b/data/presets.json new file mode 100644 index 0000000..88c93ca --- /dev/null +++ b/data/presets.json @@ -0,0 +1,278 @@ +{ + "domains": [ + { + "id": "ai-ml", + "name": "AI / Machine Learning", + "name_zh": "人工智能 / 机器学习", + "keywords": ["ai", "ml", "llm", "大模型", "机器学习", "deep learning", "深度学习", "neural network", "gpt", "transformer", "diffusion", "生成式", "nlp", "自然语言处理", "computer vision", "计算机视觉"], + "sources": [ + { + "type": "reddit_subreddit", + "description": "Top ML research discussions", + "description_zh": "机器学习研究讨论", + "tags": ["ml", "research"], + "config": { "subreddit": "MachineLearning", "sort": "hot", "fetch_limit": 15, "min_score": 60 } + }, + { + "type": "reddit_subreddit", + "description": "Local LLM community — model releases, benchmarks, deployment tips", + "description_zh": "本地大模型社区 — 模型发布、评测、部署技巧", + "tags": ["llm", "local"], + "config": { "subreddit": "LocalLLaMA", "sort": "hot", "fetch_limit": 15, "min_score": 60 } + }, + { + "type": "rss", + "description": "Simon Willison's blog — LLM tools and experiments", + "description_zh": "Simon Willison 博客 — LLM 工具与实验", + "tags": ["llm", "tools"], + "config": { "name": "Simon Willison", "url": "https://simonwillison.net/atom/everything/", "category": "ai-tools" } + }, + { + "type": "github_user", + "description": "Andrej Karpathy — AI educator and researcher", + "description_zh": "Andrej Karpathy — AI 教育者与研究者", + "tags": ["ml", "education"], + "config": { "username": "karpathy" } + }, + { + "type": "github_repo", + "description": "vLLM — high-throughput LLM serving engine", + "description_zh": "vLLM — 高吞吐量大模型推理引擎", + "tags": ["llm", "inference"], + "config": { "owner": "vllm-project", "repo": "vllm" } + }, + { + "type": "rss", + "description": "QbitAI (量子位) — Chinese AI news and research coverage", + "description_zh": "量子位 — AI 资讯与前沿报道(微信公众号)", + "tags": ["ai", "news", "chinese"], + "config": { "name": "量子位", "url": "https://wechat2rss.xlab.app/feed/7131b577c61365cb47e81000738c10d872685908.xml", "category": "ai-news" } + }, + { + "type": "rss", + "description": "AI新智元 — Chinese AI news, model releases and industry insights", + "description_zh": "新智元 — AI 资讯、模型发布与行业洞察(微信公众号)", + "tags": ["ai", "news", "chinese"], + "config": { "name": "新智元", "url": "https://wechat2rss.xlab.app/feed/ede30346413ea70dbef5d485ea5cbb95cca446e7.xml", "category": "ai-news" } + } + ] + }, + { + "id": "systems", + "name": "Systems / Infrastructure", + "name_zh": "系统 / 基础设施", + "keywords": ["linux", "kernel", "os", "操作系统", "内核", "container", "容器", "kubernetes", "k8s", "docker", "distributed", "分布式", "database", "数据库", "storage", "network", "网络", "performance", "性能"], + "sources": [ + { + "type": "rss", + "description": "LWN.net — Linux and kernel news", + "description_zh": "LWN.net — Linux 与内核新闻", + "tags": ["linux", "kernel"], + "config": { "name": "LWN.net", "url": "https://lwn.net/headlines/rss", "category": "linux-kernel" } + }, + { + "type": "reddit_subreddit", + "description": "Linux community discussions", + "description_zh": "Linux 社区讨论", + "tags": ["linux"], + "config": { "subreddit": "linux", "sort": "hot", "fetch_limit": 15, "min_score": 100 } + }, + { + "type": "rss", + "description": "Brendan Gregg's blog — performance and systems", + "description_zh": "Brendan Gregg 博客 — 性能与系统", + "tags": ["performance", "systems"], + "config": { "name": "Brendan Gregg", "url": "https://www.brendangregg.com/blog/rss.xml", "category": "systems" } + }, + { + "type": "github_user", + "description": "Linus Torvalds — Linux creator", + "description_zh": "Linus Torvalds — Linux 之父", + "tags": ["linux", "kernel"], + "config": { "username": "torvalds" } + } + ] + }, + { + "id": "security", + "name": "Security / Privacy", + "name_zh": "安全 / 隐私", + "keywords": ["security", "安全", "vulnerability", "漏洞", "exploit", "cve", "privacy", "隐私", "cryptography", "密码学", "malware", "恶意软件", "pentest", "渗透测试", "reverse engineering", "逆向"], + "sources": [ + { + "type": "reddit_subreddit", + "description": "Netsec — information security community", + "description_zh": "Netsec — 信息安全社区", + "tags": ["security"], + "config": { "subreddit": "netsec", "sort": "hot", "fetch_limit": 15, "min_score": 50 } + }, + { + "type": "rss", + "description": "Krebs on Security — investigative security journalism", + "description_zh": "Krebs on Security — 安全调查报道", + "tags": ["security", "journalism"], + "config": { "name": "Krebs on Security", "url": "https://krebsonsecurity.com/feed/", "category": "security" } + }, + { + "type": "rss", + "description": "Schneier on Security — security analysis and commentary", + "description_zh": "Schneier on Security — 安全分析与评论", + "tags": ["security", "analysis"], + "config": { "name": "Schneier on Security", "url": "https://www.schneier.com/feed/atom/", "category": "security" } + } + ] + }, + { + "id": "webdev", + "name": "Web Development", + "name_zh": "Web 开发", + "keywords": ["web", "frontend", "前端", "backend", "后端", "javascript", "typescript", "react", "vue", "node", "css", "html", "fullstack", "全栈", "api", "rest", "graphql"], + "sources": [ + { + "type": "reddit_subreddit", + "description": "Web development community", + "description_zh": "Web 开发社区", + "tags": ["web", "frontend", "backend"], + "config": { "subreddit": "webdev", "sort": "hot", "fetch_limit": 15, "min_score": 80 } + }, + { + "type": "reddit_subreddit", + "description": "JavaScript discussions and news", + "description_zh": "JavaScript 讨论与新闻", + "tags": ["javascript"], + "config": { "subreddit": "javascript", "sort": "hot", "fetch_limit": 10, "min_score": 60 } + }, + { + "type": "rss", + "description": "CSS-Tricks — frontend tips and techniques", + "description_zh": "CSS-Tricks — 前端技巧与技术", + "tags": ["css", "frontend"], + "config": { "name": "CSS-Tricks", "url": "https://css-tricks.com/feed/", "category": "frontend" } + } + ] + }, + { + "id": "pl", + "name": "Programming Languages / Compilers", + "name_zh": "编程语言 / 编译器", + "keywords": ["programming language", "编程语言", "compiler", "编译器", "rust", "go", "zig", "haskell", "ocaml", "type system", "类型系统", "llvm", "wasm", "webassembly", "functional", "函数式"], + "sources": [ + { + "type": "reddit_subreddit", + "description": "Programming language design and theory", + "description_zh": "编程语言设计与理论", + "tags": ["pl", "theory"], + "config": { "subreddit": "ProgrammingLanguages", "sort": "hot", "fetch_limit": 15, "min_score": 30 } + }, + { + "type": "reddit_subreddit", + "description": "Rust programming community", + "description_zh": "Rust 编程社区", + "tags": ["rust"], + "config": { "subreddit": "rust", "sort": "hot", "fetch_limit": 15, "min_score": 60 } + }, + { + "type": "github_repo", + "description": "Rust language releases", + "description_zh": "Rust 语言版本发布", + "tags": ["rust"], + "config": { "owner": "rust-lang", "repo": "rust" } + }, + { + "type": "github_repo", + "description": "Zig language releases", + "description_zh": "Zig 语言版本发布", + "tags": ["zig"], + "config": { "owner": "ziglang", "repo": "zig" } + } + ] + }, + { + "id": "embedded", + "name": "Embedded / Robotics / Hardware", + "name_zh": "嵌入式 / 机器人 / 硬件", + "keywords": ["embedded", "嵌入式", "robotics", "机器人", "iot", "物联网", "arduino", "raspberry pi", "rtos", "fpga", "risc-v", "hardware", "硬件", "具身智能", "embodied"], + "sources": [ + { + "type": "reddit_subreddit", + "description": "Robotics research and projects", + "description_zh": "机器人研究与项目", + "tags": ["robotics"], + "config": { "subreddit": "robotics", "sort": "hot", "fetch_limit": 15, "min_score": 30 } + }, + { + "type": "reddit_subreddit", + "description": "Embedded systems development", + "description_zh": "嵌入式系统开发", + "tags": ["embedded"], + "config": { "subreddit": "embedded", "sort": "hot", "fetch_limit": 15, "min_score": 30 } + }, + { + "type": "rss", + "description": "Hackaday — hardware hacking and projects", + "description_zh": "Hackaday — 硬件创客与项目", + "tags": ["hardware", "maker"], + "config": { "name": "Hackaday", "url": "https://hackaday.com/feed/", "category": "hardware" } + } + ] + }, + { + "id": "devtools", + "name": "Open Source / DevTools", + "name_zh": "开源 / 开发工具", + "keywords": ["open source", "开源", "devtools", "开发工具", "cli", "terminal", "终端", "editor", "编辑器", "vim", "neovim", "emacs", "vscode", "git", "ci/cd", "devops"], + "sources": [ + { + "type": "rss", + "description": "GitHub Trending — daily popular repositories", + "description_zh": "GitHub Trending — 每日热门仓库", + "tags": ["github", "trending"], + "config": { "name": "GitHub Trending - Daily", "url": "https://mshibanami.github.io/GitHubTrendingRSS/daily/all.xml", "category": "github-trending" } + }, + { + "type": "reddit_subreddit", + "description": "Command line tools and tips", + "description_zh": "命令行工具与技巧", + "tags": ["cli", "terminal"], + "config": { "subreddit": "commandline", "sort": "hot", "fetch_limit": 10, "min_score": 40 } + }, + { + "type": "github_repo", + "description": "Neovim editor releases", + "description_zh": "Neovim 编辑器版本发布", + "tags": ["editor", "neovim"], + "config": { "owner": "neovim", "repo": "neovim" } + } + ] + }, + { + "id": "science", + "name": "Science / Research", + "name_zh": "科学 / 学术研究", + "keywords": ["science", "科学", "research", "研究", "paper", "论文", "arxiv", "physics", "物理", "math", "数学", "biology", "生物", "chemistry", "化学", "neuroscience", "神经科学"], + "sources": [ + { + "type": "reddit_subreddit", + "description": "Science news and discussions", + "description_zh": "科学新闻与讨论", + "tags": ["science", "general"], + "config": { "subreddit": "science", "sort": "hot", "fetch_limit": 15, "min_score": 200 } + }, + { + "type": "rss", + "description": "Nature — latest research highlights", + "description_zh": "Nature — 最新研究亮点", + "tags": ["research", "journal"], + "config": { "name": "Nature", "url": "https://www.nature.com/nature.rss", "category": "science" } + }, + { + "type": "rss", + "description": "Quanta Magazine — accessible science journalism", + "description_zh": "Quanta Magazine — 通俗科学报道", + "tags": ["science", "journalism"], + "config": { "name": "Quanta Magazine", "url": "https://api.quantamagazine.org/feed/", "category": "science" } + } + ] + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc8455d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + horizon: + build: . + container_name: horizon + volumes: + - ./data:/app/data + - ./.env:/app/.env:ro + environment: + - TZ=UTC + restart: unless-stopped + # Run daily at 8:00 AM UTC (adjust as needed) + # For one-time run, use: docker compose run --rm horizon + command: ["--hours", "24"] diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..0174c02 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,23 @@ +theme: jekyll-theme-cayman +title: Horizon Daily +description: AI-curated daily digest of tech and research news +baseurl: "/Horizon" # the subpath of your site, e.g. /blog +url: "https://thysrael.github.io" # the base hostname & protocol for your site, e.g. http://example.com + +# Build settings +markdown: kramdown +future: true # Allow future posts to be published +plugins: + - jekyll-feed +feed: + path: feed.xml +exclude: + - ".github" + - "node_modules" + +defaults: + - scope: + path: "" + type: "posts" + values: + layout: "default" diff --git a/docs/_includes/head-custom.html b/docs/_includes/head-custom.html new file mode 100644 index 0000000..429a6b7 --- /dev/null +++ b/docs/_includes/head-custom.html @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/docs/assets/compshare-logo.png b/docs/assets/compshare-logo.png new file mode 100644 index 0000000..63daeda Binary files /dev/null and b/docs/assets/compshare-logo.png differ diff --git a/docs/assets/css/horizon.css b/docs/assets/css/horizon.css new file mode 100644 index 0000000..be4c3a8 --- /dev/null +++ b/docs/assets/css/horizon.css @@ -0,0 +1,191 @@ +/* ===== Horizon Custom Styles ===== */ + +/* --- CSS Variables & Dark Mode (Horizon Dawn) --- */ +/* Palette derived from horizon-header.svg sunrise: + #312e81 deep indigo · #be185d rose · #f97316 orange · #fbbf24 gold + #4c1d95 mountain purple · #701a75 distant purple */ +:root { + --hz-bg: #faf8f5; /* warm parchment white */ + --hz-surface: #f3f0eb; /* slightly warm gray */ + --hz-border: #e0dbd3; /* warm border */ + --hz-text: #2d2a3e; /* deep indigo-ink */ + --hz-text-muted: #7c7891; /* muted lavender gray */ + --hz-link: #6d4aaa; /* mountain purple, lighter */ + --hz-code-bg: #f0ede7; /* warm code bg */ + --hz-accent: #e0652e; /* sunrise orange, toned down */ +} + +@media (prefers-color-scheme: dark) { + :root { + --hz-bg: #1a1726; /* pre-dawn deep purple-black */ + --hz-surface: #231f33; /* night sky surface */ + --hz-border: #342f4a; /* indigo border */ + --hz-text: #e8e4f0; /* moonlight white-lavender */ + --hz-text-muted: #8c86a0; /* muted twilight */ + --hz-link: #f0a848; /* dawn gold */ + --hz-code-bg: #211d30; /* deep code bg */ + --hz-accent: #e87040; /* sunrise orange-coral */ + } +} + +/* Apply variables to base elements */ +body { + background-color: var(--hz-bg) !important; + color: var(--hz-text) !important; +} + +a { + color: var(--hz-link) !important; +} + +code { + background-color: var(--hz-code-bg) !important; + color: var(--hz-text) !important; +} + +pre { + background-color: var(--hz-code-bg) !important; + color: var(--hz-text) !important; + border: 1px solid var(--hz-border) !important; +} + +hr { + border-color: var(--hz-border) !important; +} + +h1, h2, h3, h4, h5, h6 { + color: var(--hz-text) !important; + font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Helvetica Neue", Arial, sans-serif !important; + font-weight: 800 !important; +} + +/* Override Cayman header gradient — Horizon sunrise from SVG */ +.page-header { + background: linear-gradient(120deg, #312e81, #be185d, #f97316) !important; +} + +.page-header h1, +.page-header h2, +.page-header .project-tagline, +.page-header a { + color: #fff !important; +} + +/* --- Score Badge --- */ +.score-badge { + display: inline-block; + padding: 0.1em 0.5em; + border-radius: 3px; + font-family: monospace; + font-size: 0.75em; + font-weight: 700; + color: #fff; + margin-left: 0.4em; + vertical-align: middle; +} + +.score-badge[data-tier="high"] { background: #be185d; } /* rose — from SVG */ +.score-badge[data-tier="good"] { background: #e0652e; } /* sunrise orange */ +.score-badge[data-tier="mid"] { background: #d4a017; } /* warm gold */ +.score-badge[data-tier="low"] { background: #7c7891; } /* muted lavender */ + +/* --- Tag Pills --- */ +.tag-line code { + border-radius: 3px; + padding: 0.1em 0.5em; + font-size: 0.8em; + background: var(--hz-accent) !important; + color: #fff !important; +} + +@media (prefers-color-scheme: dark) { + .tag-line code { + background: #342f4a !important; /* indigo border */ + color: #f0a848 !important; /* dawn gold */ + } +} + +/* --- Source Line --- */ +.source-line { + font-size: 0.85em; + color: var(--hz-text-muted); + border-left: 2px solid var(--hz-accent); + padding-left: 0.6em; +} + +/* --- Collapsible References --- */ +.main-content details { + border: 1px solid var(--hz-border); + border-radius: 4px; + margin: 0.8em 0; +} + +.main-content details summary { + cursor: pointer; + padding: 0.4em 0.8em; + font-weight: 600; + color: var(--hz-text-muted); +} + +.main-content details > ul { + padding: 0.4em 0.8em 0.4em 2em; + margin: 0; +} + +/* --- TOC Ordered List --- */ +.main-content > ol { + border: 1px solid var(--hz-border); + border-radius: 4px; + padding: 0.8em 0.8em 0.8em 2em; + background: var(--hz-surface); +} + +.main-content > ol > li { + padding: 0.3em 0; +} + +/* --- Summary Blockquote --- */ +.main-content > blockquote:first-of-type { + border-left-color: var(--hz-accent); + color: var(--hz-text-muted); + font-style: normal; +} + +/* --- Language Toggle (fixed top-right) --- */ +.lang-toggle { + position: fixed; + top: 12px; + right: 16px; + z-index: 1000; + display: flex; + gap: 0; +} + +.lang-toggle button { + padding: 0.3em 1em; + border: 1px solid rgba(255,255,255,0.4); + background: rgba(0,0,0,0.15); + color: #fff; + font-size: 0.8em; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.lang-toggle button:first-child { + border-radius: 3px 0 0 3px; +} + +.lang-toggle button:last-child { + border-radius: 0 3px 3px 0; + border-left: none; +} + +.lang-toggle button.active { + background: var(--hz-accent); + color: #fff; + border-color: var(--hz-accent); +} + +.lang-section.hidden { + display: none; +} diff --git a/docs/assets/email.png b/docs/assets/email.png new file mode 100644 index 0000000..4270ce0 Binary files /dev/null and b/docs/assets/email.png differ diff --git a/docs/assets/feishu_en.png b/docs/assets/feishu_en.png new file mode 100644 index 0000000..b3b4255 Binary files /dev/null and b/docs/assets/feishu_en.png differ diff --git a/docs/assets/feishu_zh.png b/docs/assets/feishu_zh.png new file mode 100644 index 0000000..85dbd1f Binary files /dev/null and b/docs/assets/feishu_zh.png differ diff --git a/docs/assets/horizon-header.svg b/docs/assets/horizon-header.svg new file mode 100644 index 0000000..34c0890 --- /dev/null +++ b/docs/assets/horizon-header.svg @@ -0,0 +1 @@ + HORIZON INTELLIGENCE AGGREGATOR \ No newline at end of file diff --git a/docs/assets/js/horizon.js b/docs/assets/js/horizon.js new file mode 100644 index 0000000..696f9dd --- /dev/null +++ b/docs/assets/js/horizon.js @@ -0,0 +1,132 @@ +(function () { + 'use strict'; + + /** Replace ⭐️ N/10 with a colored badge in h2, h3, and li elements */ + function processScoreBadges() { + var scoreRe = /⭐️\s*(\d+(?:\.\d+)?)\/10/; + var targets = document.querySelectorAll('.main-content h2, .main-content h3, .main-content li'); + targets.forEach(function (el) { + var m = el.innerHTML.match(scoreRe); + if (!m) return; + var score = parseFloat(m[1]); + var tier; + if (score >= 9) tier = 'high'; + else if (score >= 7) tier = 'good'; + else if (score >= 5) tier = 'mid'; + else tier = 'low'; + el.innerHTML = el.innerHTML.replace( + scoreRe, + '' + m[1] + '' + ); + }); + } + + /** Add semantic classes to tag lines, source lines, and background paragraphs */ + function markSemanticElements() { + var paragraphs = document.querySelectorAll('.main-content p'); + paragraphs.forEach(function (p) { + var text = p.textContent.trim(); + + // Tag line: starts with Tags or 标签 (bold prefix rendered by Markdown) + if (/^(Tags|标签)\s*:/.test(text)) { + p.classList.add('tag-line'); + return; + } + + // Source line: pattern like "source · site · date" + if (/^(rss|reddit|github|hackernews|hn|telegram)\s*·/i.test(text)) { + p.classList.add('source-line'); + return; + } + }); + } + + /** Set up EN/中文 language toggle as a page-level control */ + function setupLanguageToggle() { + // Create toggle buttons + var toggle = document.createElement('div'); + toggle.className = 'lang-toggle'; + + var btnEn = document.createElement('button'); + btnEn.textContent = 'EN'; + btnEn.type = 'button'; + + var btnZh = document.createElement('button'); + btnZh.textContent = '中文'; + btnZh.type = 'button'; + + toggle.appendChild(btnEn); + toggle.appendChild(btnZh); + + // Insert at top of body + document.body.insertBefore(toggle, document.body.firstChild); + + // Read saved preference, default to zh + var saved = null; + try { saved = localStorage.getItem('horizon-lang'); } catch (e) { /* noop */ } + var currentLang = saved === 'en' ? 'en' : 'zh'; + + function updateButtons(lang) { + if (lang === 'en') { + btnEn.classList.add('active'); + btnZh.classList.remove('active'); + } else { + btnZh.classList.add('active'); + btnEn.classList.remove('active'); + } + } + + // Index page: toggle lang-section visibility + var zhSection = document.getElementById('lang-zh'); + var enSection = document.getElementById('lang-en'); + + function showSection(lang) { + if (!zhSection || !enSection) return; + if (lang === 'en') { + enSection.classList.remove('hidden'); + zhSection.classList.add('hidden'); + } else { + zhSection.classList.remove('hidden'); + enSection.classList.add('hidden'); + } + } + + // Article page: redirect to the other language version + function switchArticleLang(lang) { + var path = window.location.pathname; + var target = null; + if (lang === 'en' && /-zh(?:\.html)?$/.test(path.replace(/\/$/, ''))) { + target = path.replace(/-zh(\.html)?$/, '-en$1').replace(/-zh\/$/, '-en/'); + } else if (lang === 'zh' && /-en(?:\.html)?$/.test(path.replace(/\/$/, ''))) { + target = path.replace(/-en(\.html)?$/, '-zh$1').replace(/-en\/$/, '-zh/'); + } + if (target) window.location.href = target; + } + + function setLang(lang) { + currentLang = lang; + updateButtons(lang); + try { localStorage.setItem('horizon-lang', lang); } catch (e) { /* noop */ } + if (zhSection && enSection) { + showSection(lang); + } else { + switchArticleLang(lang); + } + } + + btnEn.addEventListener('click', function () { setLang('en'); }); + btnZh.addEventListener('click', function () { setLang('zh'); }); + + // Initialize + updateButtons(currentLang); + if (zhSection && enSection) { + showSection(currentLang); + } + } + + document.addEventListener('DOMContentLoaded', function () { + processScoreBadges(); + markSemanticElements(); + setupLanguageToggle(); + }); +})(); diff --git a/docs/assets/one_news_en.png b/docs/assets/one_news_en.png new file mode 100644 index 0000000..d3ba135 Binary files /dev/null and b/docs/assets/one_news_en.png differ diff --git a/docs/assets/one_news_zh.png b/docs/assets/one_news_zh.png new file mode 100644 index 0000000..f4819b5 Binary files /dev/null and b/docs/assets/one_news_zh.png differ diff --git a/docs/assets/overview_en.png b/docs/assets/overview_en.png new file mode 100644 index 0000000..0788eaf Binary files /dev/null and b/docs/assets/overview_en.png differ diff --git a/docs/assets/overview_zh.png b/docs/assets/overview_zh.png new file mode 100644 index 0000000..07eb66f Binary files /dev/null and b/docs/assets/overview_zh.png differ diff --git a/docs/assets/terminal_log.png b/docs/assets/terminal_log.png new file mode 100644 index 0000000..2743cdc Binary files /dev/null and b/docs/assets/terminal_log.png differ diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..8b59f29 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,761 @@ +--- +layout: default +title: Configuration Guide +--- + +# Configuration Guide + +Horizon is configured through two files: a `.env` file for API keys and a `data/config.json` file for sources, AI provider, and filtering options. + +## AI Providers + +Configure which AI model scores and summarizes your content. + +`api_key_env` is always an environment variable name, not the API key value. +Store secrets in `.env` or your shell environment, then point `api_key_env` at +that variable: + +```bash +OPENAI_API_KEY=sk-your-key +GOOGLE_API_KEY=your-gemini-key +``` + +When Horizon starts, environment variables have priority because +`data/config.json` does not store the secret. For local VS Code runs, create +`.env` in the repository root and launch Horizon from that same root directory. + +Common API key variable names: + +| Provider | `api_key_env` value | +| --- | --- | +| Anthropic | `ANTHROPIC_API_KEY` | +| OpenAI | `OPENAI_API_KEY` | +| Azure OpenAI | `AZURE_OPENAI_API_KEY` | +| Gemini | `GOOGLE_API_KEY` | +| MiniMax | `MINIMAX_API_KEY` | +| Aliyun DashScope | `DASHSCOPE_API_KEY` | +| Doubao | `DOUBAO_API_KEY` | +| DeepSeek | `DEEPSEEK_API_KEY` | + +**Anthropic Claude**: + +```json +{ + "ai": { + "provider": "anthropic", + "model": "claude-sonnet-4.5-20250929", + "api_key_env": "ANTHROPIC_API_KEY", + "throttle_sec": 0 + } +} +``` + +**OpenAI**: + +```json +{ + "ai": { + "provider": "openai", + "model": "gpt-4", + "api_key_env": "OPENAI_API_KEY", + "throttle_sec": 0 + } +} +``` + +**Gemini**: + +```json +{ + "ai": { + "provider": "gemini", + "model": "gemini-2.0-flash", + "api_key_env": "GOOGLE_API_KEY", + "throttle_sec": 0 + } +} +``` + +**Azure OpenAI**: + +```json +{ + "ai": { + "provider": "azure", + "model": "gpt-4o-production", + "api_key_env": "AZURE_OPENAI_API_KEY", + "azure_endpoint_env": "AZURE_OPENAI_ENDPOINT", + "api_version": "2024-10-21", + "throttle_sec": 0 + } +} +``` + +Set `AZURE_OPENAI_API_KEY` and `AZURE_OPENAI_ENDPOINT` in your `.env`. The `model` field should be your Azure deployment name, not just the base model family name. + +**MiniMax**: + +```json +{ + "ai": { + "provider": "minimax", + "model": "MiniMax-M3", + "api_key_env": "MINIMAX_API_KEY", + "throttle_sec": 0 + } +} +``` + +Available models: `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` + +**Aliyun DashScope** (OpenAI-compatible): + +```json +{ + "ai": { + "provider": "ali", + "model": "qwen-plus", + "api_key_env": "DASHSCOPE_API_KEY", + "throttle_sec": 0 + } +} +``` + +Use the [DashScope compatible-mode](https://help.aliyun.com/zh/dashscope/developer-reference/use-dashscope-by-calling-openai-api) endpoint. Set `DASHSCOPE_API_KEY` in your `.env`. Optional: set `base_url` to override the default `https://dashscope.aliyuncs.com/compatible-mode/v1`. + +**Ollama**: + +```json +{ + "ai": { + "provider": "ollama", + "model": "llama3.1", + "api_key_env": "", + "base_url": "http://192.168.1.10:11434", + "throttle_sec": 0 + } +} +``` + +Omit `base_url` to use the default `http://localhost:11434/v1`. +For remote Ollama servers, set `ai.base_url` in `data/config.json` or set +`HORIZON_OLLAMA_BASE_URL` in `.env`. `OLLAMA_BASE_URL` and `OLLAMA_HOST` are +also recognized. If the value omits `/v1`, Horizon appends it automatically +for Ollama's OpenAI-compatible endpoint. + +### AI throttling + +If your model has a strict per-minute request cap, you can slow the scorer down in `data/config.json`: + +```json +{ + "ai": { + "throttle_sec": 4.5 + } +} +``` + +- `throttle_sec`: Pause between scored items in seconds. Default is `0`. +- `4.5` is a reasonable starting point for free-tier models capped around 15 requests per minute. +- Set it back to `0` if you have enough throughput headroom and want maximum speed. + +### AI Concurrency + +By default, AI scoring and enrichment run one item at a time. If your API endpoint supports concurrent requests, you can increase throughput: + +```json +{ + "ai": { + "analysis_concurrency": 4, + "enrichment_concurrency": 2 + } +} +``` + +- `analysis_concurrency`: Number of items scored in parallel. Default is `1`. +- `enrichment_concurrency`: Number of high-scoring items enriched in parallel. Default is `1`. +- Both values are clamped to a minimum of `1`. +- Preserve the existing retry behavior per item. +- Result ordering is preserved regardless of concurrency. +- If you also use `throttle_sec`, each concurrent task sleeps independently after finishing an item. + +**Custom Base URL** (for proxies): + +```json +{ + "ai": { + "provider": "anthropic", + "base_url": "https://your-proxy.com/v1", + ... + } +} +``` + +For OpenAI-compatible gateways, Horizon sends `temperature` by default. If a newer reasoning-style model rejects that parameter with an error such as `temperature is deprecated for this model`, Horizon retries once without it and remembers that capability for later requests. + +## Information Sources + +All sources are configured under the top-level `sources` key in `config.json`. + +### GitHub + +```json +{ + "sources": { + "github": [ + { + "type": "user_events", + "username": "gvanrossum", + "enabled": true, + "category": "oss" + }, + { + "type": "repo_releases", + "owner": "python", + "repo": "cpython", + "enabled": true, + "category": "oss" + } + ] + } +} +``` + +### Hacker News + +```json +{ + "sources": { + "hackernews": { + "enabled": true, + "fetch_top_stories": 30, + "min_score": 100, + "category": "tech" + } + } +} +``` + +### RSS Feeds + +```json +{ + "sources": { + "rss": [ + { + "name": "Blog Name", + "url": "https://example.com/feed.xml", + "enabled": true, + "category": "ai-ml" + } + ] + } +} +``` + +### Reddit + +Reddit scraping is free and does not require API keys. Subreddit posts and comments prefer `old.reddit.com`; JSON and RSS endpoints are used as fallbacks when needed. + +```json +{ + "sources": { + "reddit": { + "enabled": true, + "fetch_comments": 5, + "subreddits": [ + { + "subreddit": "MachineLearning", + "sort": "hot", + "fetch_limit": 25, + "min_score": 10, + "category": "ai-ml" + } + ], + "users": [ + { + "username": "spez", + "sort": "new", + "fetch_limit": 10, + "category": "social" + } + ] + } + } +} +``` + +### Telegram + +Telegram scraping uses the public web preview at `https://t.me/s/`, so no API key is required. Only public channels are supported. + +```json +{ + "sources": { + "telegram": { + "enabled": true, + "channels": [ + { + "channel": "zaihuapd", + "enabled": true, + "fetch_limit": 20, + "category": "ai-news" + } + ] + } + } +} +``` + +- `enabled` — enable or disable Telegram fetching globally +- `channels` — list of public Telegram channels to monitor +- `channel` — Telegram channel username only, without `@` or the full `https://t.me/` URL +- `fetch_limit` — maximum number of recent messages to inspect per channel per run (default: `20`) +- `category` — optional tag for balanced digest grouping (e.g., `"ai-news"`, `"finance"`) + +### Twitter + +Requires an [Apify](https://apify.com) account. Set `APIFY_TOKEN` in your `.env` file. The free tier includes $5/month of credit, enough for roughly 20,000 tweets. + +```json +{ + "sources": { + "twitter": { + "enabled": true, + "users": ["karpathy", "ylecun"], + "fetch_limit": 10, + "category": "social", + "fetch_reply_text": false, + "max_replies_per_tweet": 3, + "max_tweets_to_expand": 10, + "reply_min_likes": 5 + } + } +} +``` + +- `users` — Twitter screen names to monitor, without the `@` prefix +- `fetch_limit` — maximum tweets to fetch per run (across all users combined; minimum 100 due to actor constraint) +- `category` — optional tag for balanced digest grouping (applies to all tweets from this source) +- `fetch_reply_text` — when `true`, fetch actual reply bodies for important tweets and append them under `--- Top Comments ---` so the AI can factor in community discussion. Disabled by default. +- `max_replies_per_tweet` — maximum reply lines to append per tweet (default: 3) +- `max_tweets_to_expand` — cap on how many tweets get reply expansion per run, to control Apify credit usage (default: 10) +- `reply_min_likes` — only include replies with at least this many likes (default: 0) + +The scraper uses the `altimis/scweet` actor by default. You can override it with `actor_id` if needed. + +### OpenBB Financial News + +OpenBB is useful when you want equity or macro news from providers such as yfinance, Benzinga, FMP, Intrinio, Tiingo, SEC, or Federal Reserve through one SDK. + +Install the optional dependency before enabling the source: + +```bash +uv sync --extra openbb +``` + +If your platform struggles to build transitive dependencies, prefer: + +```bash +uv pip install --only-binary=:all: openbb openbb-benzinga +``` + +```json +{ + "sources": { + "openbb": { + "enabled": true, + "watchlists": [ + { + "name": "megacaps", + "enabled": true, + "provider": "yfinance", + "fetch_limit": 20, + "category": "equities", + "symbols": ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META", "TSLA"] + } + ] + } + } +} +``` + +- `enabled` — enable or disable the OpenBB source globally +- `watchlists` — list of named ticker groups; each watchlist becomes one `news.company()` call per run +- `name` — label shown in Horizon metadata and selection breakdowns +- `provider` — OpenBB provider name such as `yfinance` or `benzinga` +- `fetch_limit` — maximum news rows requested for that watchlist +- `category` — optional tag stored on fetched items +- `symbols` — ticker symbols to fetch together; group symbols by provider to keep requests efficient + +OpenBB provider credentials are handled by the OpenBB SDK itself, using its own environment variables or user settings. Horizon does not pass those secrets through `data/config.json`. + +### OSS Insight (Trending GitHub Repos) + +Pulls top star-gain repositories from the [OSS Insight](https://ossinsight.io) public API, which aggregates GitHub WatchEvents. Useful for surfacing repos that are gaining stars right now without needing to scrape GitHub Trending or query BigQuery. + +```json +{ + "sources": { + "ossinsight": { + "enabled": true, + "period": "past_24_hours", + "languages": ["All", "Python", "TypeScript"], + "keywords": [], + "min_stars": 10, + "max_items": 30, + "category": "oss-trending" + } + } +} +``` + +- `period` — time window for star-gain ranking. Supported: `past_24_hours`, `past_28_days`. (`past_7_days` is currently broken upstream.) +- `languages` — primary language buckets to query. Use `"All"` for the full ranking, or any GitHub language label such as `"Python"`, `"TypeScript"`, `"Rust"`, `"Jupyter Notebook"`. The scraper fans out one request per language and merges results. +- `keywords` — optional case-insensitive substrings matched against `description`, `collection_names`, and `repo_name`. Only repos containing at least one keyword pass through. Leave empty to ingest everything trending. +- `min_stars` — drop repos with fewer than this many stars gained in the period. +- `max_items` — final cap after merging and sorting by `stars_gained` descending. +- `category` — optional tag for balanced digest grouping (e.g., `"oss-trending"`) + +No API key is required. + +## Filtering + +Content is scored 0-10: + +- **9-10**: Groundbreaking - Major breakthroughs, paradigm shifts +- **7-8**: High Value - Important developments, deep technical content +- **5-6**: Interesting - Worth knowing but not urgent +- **3-4**: Low Priority - Generic or routine content +- **0-2**: Noise - Spam, off-topic, or trivial + +```json +{ + "filtering": { + "ai_score_threshold": 7.0, + "time_window_hours": 24, + "max_items": 20, + "category_groups": { + "ai": { + "name": "AI / Machine Learning", + "limit": 5, + "categories": ["ai-news", "ai-tools", "machine-learning", "llm"] + }, + "finance": { + "name": "Finance", + "limit": 5, + "categories": ["finance", "equities", "crypto"] + } + }, + "default_group": "other", + "default_group_limit": 3 + } +} +``` + +- `ai_score_threshold`: Only include content scoring >= this value +- `time_window_hours`: Fetch content from last N hours +- `max_items`: Optional final cap after all group limits are applied +- `category_groups`: Optional map of quota groups. Each group requires a positive + `limit` and a non-empty `categories` list. Items within each group are kept by + AI score, highest first. +- `category_groups.*.name`: Optional display name used in run logs +- `default_group`: Group key for items whose category does not match any + configured group. Default is `other`. +- `default_group_limit`: Optional positive limit for unmatched items. If omitted, + unmatched items are unlimited except for `max_items`. + +Balanced digest filtering runs after AI score threshold filtering and topic +deduplication, but before enrichment. This reduces enrichment calls to only the +items that can appear in the final digest. + +Group matching uses the source category stored in `ContentItem.metadata.category`. +All source types support a `category` field: `sources.rss[].category`, +`sources.github[].category`, `sources.hackernews.category`, +`sources.reddit.subreddits[].category`, `sources.reddit.users[].category`, +`sources.telegram.channels[].category`, `sources.twitter.category`, +`sources.openbb.watchlists[].category`, `sources.ossinsight.category`, +`sources.gdelt.category`, and `sources.google_news.category`. +Sources without a category set enter the default group. + +If the same category appears in multiple groups, Horizon logs a warning and uses +the first group in configuration order. Omitting both `category_groups` and +`max_items` preserves the previous filtering behavior. + +## Environment Variable Substitution + +Any string value in `data/config.json` supports `${VAR_NAME}` syntax. Variables are expanded at runtime from the environment (including values loaded from `.env`). This lets you keep secrets, tenant-specific endpoints, and private URLs out of the checked-in JSON file. + +Example: + +```json +{ + "ai": { + "base_url": "${HORIZON_AI_BASE_URL}" + }, + "sources": { + "rss": [ + { + "name": "LWN.net", + "url": "https://lwn.net/headlines/full_text?key=${LWN_KEY}", + "enabled": true + } + ] + }, + "webhook": { + "url_env": "HORIZON_WEBHOOK_URL", + "headers": "Authorization: Bearer ${HORIZON_WEBHOOK_TOKEN}" + } +} +``` + +- `${NAME}` is replaced only when `NAME` is a valid identifier like `LWN_KEY` or `HORIZON_AI_BASE_URL`. +- Unset variables are left as `${NAME}` instead of becoming an empty string, so configuration mistakes fail loudly downstream. +- Expansion is recursive through dicts, lists, and tuples; non-string values are left unchanged. + +## Email Subscription + +Email delivery is optional and disabled unless `email.enabled` is `true`. Horizon uses SMTP to send daily summaries and IMAP to check subscribe/unsubscribe requests. + +```json +{ + "email": { + "enabled": true, + "smtp_server": "smtp.qq.com", + "smtp_port": 465, + "smtp_username": null, + "imap_enabled": true, + "imap_server": "imap.qq.com", + "imap_port": 993, + "email_address": "xxx@qq.com", + "password_env": "EMAIL_PASSWORD", + "sender_name": "Horizon Daily", + "subscribe_keyword": "SUBSCRIBE", + "unsubscribe_keyword": "UNSUBSCRIBE" + } +} +``` + +- `enabled`: Turns email subscription handling and daily email delivery on or off. +- `smtp_server` / `smtp_port`: SMTP server used to send emails. +- `smtp_username`: Optional SMTP login username. If omitted, Horizon uses `email_address`. +- `imap_enabled`: Turns IMAP subscribe/unsubscribe checks on or off. Set it to `false` for send-only SMTP providers. +- `imap_server` / `imap_port`: IMAP server used to scan incoming subscription requests when `imap_enabled` is `true`. +- `email_address`: Sender account and mailbox checked for subscription requests. +- `password_env`: Environment variable containing the email password or app password. Defaults to `EMAIL_PASSWORD`. +- `sender_name`: Display name shown in sent emails. +- `subscribe_keyword` / `unsubscribe_keyword`: Keywords Horizon looks for in incoming email subjects. + +Resend SMTP example: + +```json +{ + "email": { + "enabled": true, + "smtp_server": "smtp.resend.com", + "smtp_port": 465, + "smtp_username": "resend", + "password_env": "RESEND_API_KEY", + "imap_enabled": false, + "imap_server": "", + "imap_port": 993, + "email_address": "noreply@example.com", + "sender_name": "Horizon Daily" + } +} +``` + +Set `RESEND_API_KEY` in `.env`. Recipients are loaded from `data/subscribers.json`. + +## Webhook Notification + +Webhook notification is optional and disabled unless `webhook.enabled` is `true`. Horizon can call Feishu/Lark, DingTalk, Slack, Discord, or any custom webhook endpoint when the pipeline succeeds or fails. + +```json +{ + "webhook": { + "enabled": true, + "url_env": "HORIZON_WEBHOOK_URL", + "delivery": "summary", + "overview_position": "first", + "platform": "generic", + "layout": "markdown", + "fallback_layout": "markdown", + "languages": null, + "request_body": { + "text": "#{message_title}\n#{summary}" + }, + "headers": "" + } +} +``` + +- `enabled`: Turns webhook delivery on or off. The default is `false`. +- `url_env`: Environment variable that contains the webhook URL. For example, set `HORIZON_WEBHOOK_URL=https://...` in `.env`. +- `delivery`: Controls how messages are sent. Use `summary` for one full message, or `summary_and_items` for one overview message followed by one message per selected item. +- `overview_position`: Controls where the overview is sent in `summary_and_items` mode. Use `first` for the traditional order, or `last` to send item details in reverse and keep the overview as the newest chat message. +- `platform`: Optional webhook platform hint. Use `generic` by default, or `feishu` / `lark` to enable platform-specific card rendering. +- `layout`: Controls the message layout. Use `markdown` for templated Markdown delivery, or `collapsible` with `platform: "feishu"` / `"lark"` for a single Feishu Card JSON 2.0 message with each item in a collapsed panel. +- `fallback_layout`: Reserved fallback layout for unsupported platform/layout combinations. The current safe fallback is `markdown`. +- `languages`: Optional webhook-only language filter. Use `["zh"]` or `["en"]` to send only selected languages; use `null` or omit it to send all configured `ai.languages`. +- `request_body`: Optional request body. If empty, Horizon sends a `GET` request. If provided, Horizon sends a `POST` request. +- `headers`: Optional custom headers, one `Key: Value` pair per line. + +When `request_body` is a JSON object or array, Horizon renders placeholders and serializes it as JSON. When it is a string, Horizon renders it directly and detects JSON if the rendered string is valid JSON. + +### Delivery Modes And Layouts + +`delivery` controls how many webhook messages Horizon sends: + +- `summary`: Sends one message containing the full daily summary. This is simple, but some chat platforms may reject long messages. +- `summary_and_items`: Sends one overview message plus one message per selected item. In each item message, `#{summary}` contains only that item's Markdown body. This is useful for platforms that reject or truncate long messages. + +`layout` controls how each message is rendered: + +- `markdown`: Uses your `request_body` template for each message. This is the default and works with generic webhooks, DingTalk, Slack, Discord, Feishu, and Lark. +- `collapsible`: Currently supported for `platform: "feishu"` or `"lark"`. Horizon ignores `request_body` and builds one Feishu/Lark Card JSON 2.0 message with each item in a collapsed panel. + +For platforms without a platform-specific layout, keep `layout: "markdown"` and choose the message count with `delivery`. + +Example `summary_and_items` Markdown delivery config: + +```json +{ + "webhook": { + "enabled": true, + "url_env": "HORIZON_WEBHOOK_URL", + "delivery": "summary_and_items", + "overview_position": "last", + "platform": "generic", + "layout": "markdown", + "request_body": { + "text": "#{message_title}\n\n#{summary?limit=3000&split=---}" + } + } +} +``` + +With `summary_and_items`, Horizon sends one overview plus one message per selected item. `overview_position: "last"` sends item messages first and keeps the overview as the newest chat message; omit it or set `"first"` to send the overview first. + +### Webhook Templates + +Available variables: + +| Variable | Description | +|----------|-------------| +| `#{date}` | Report date, for example `2026-04-24` | +| `#{language}` | Language code, such as `en` or `zh` | +| `#{important_items}` | Number of items that passed the score threshold | +| `#{all_items}` | Total number of fetched items | +| `#{result}` | `success` or `failed` | +| `#{timestamp}` | Unix timestamp | +| `#{message_title}` | Message title, such as the daily title, overview title, or item title | +| `#{message_kind}` | Message kind: `summary`, `overview`, `item`, `failure`, or `manual` | +| `#{summary}` | Message Markdown. In `summary_and_items` mode this is the overview or one item body, depending on the message | + +When `delivery` is `summary_and_items`, item messages also include: + +| Variable | Description | +|----------|-------------| +| `#{item_index}` | 1-based item number | +| `#{item_count}` | Total number of item messages | +| `#{item_title}` | Current item title | +| `#{item_url}` | Current item URL | +| `#{item_score}` | Current item AI score | + +For webhook delivery, Horizon flattens HTML disclosure blocks such as `
...` in `#{summary}` into plain Markdown link lists. This makes the generated summary easier to render in chat products. Saved Markdown files, GitHub Pages, and email content are unchanged. + +Use `#{key?limit=N&split=DELIM}` to truncate long values by splitting on `DELIM` and keeping segments until the total character count reaches `N`. + +```text +#{summary?limit=3000&split=---} +``` + +### DingTalk + +In DingTalk, create a custom group robot and use a custom keyword such as `Horizon`. The keyword must appear in the body content. + +```json +{ + "msgtype": "markdown", + "markdown": { + "title": "Horizon #{date} Daily", + "text": "Horizon result: #{result}\n\nHorizon important items: #{important_items}/#{all_items}\n\n#{summary}" + } +} +``` + +### Feishu / Lark + +In Feishu or Lark, create a custom group robot and use a custom keyword such as `Horizon`. The keyword must appear in the body content. + +Use Card JSON 2.0 for Markdown rendering. The card must include `"schema": "2.0"` and put rich-text Markdown components under `card.body.elements`. + +To keep the group chat compact while still allowing readers to browse the full briefing inside Feishu, use the collapsible layout: + +```json +{ + "webhook": { + "enabled": true, + "url_env": "HORIZON_WEBHOOK_URL", + "platform": "feishu", + "layout": "collapsible", + "fallback_layout": "markdown", + "languages": ["zh"] + } +} +``` + +With this layout, Horizon sends one interactive card containing the overview and one collapsed panel per selected item. Each panel can be expanded in Feishu to read the full item detail. The regular `request_body` template is ignored for this rendered card. + +```json +{ + "msg_type": "interactive", + "card": { + "schema": "2.0", + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "#{message_title}" + }, + "template": "blue" + }, + "body": { + "elements": [ + { + "tag": "markdown", + "content": "Horizon result: #{result}\nHorizon important items: #{important_items}/#{all_items}" + }, + { + "tag": "hr" + }, + { + "tag": "markdown", + "content": "#{summary}" + } + ] + } + } +} +``` + +## Static Site + +Horizon writes generated summaries to `data/summaries/` and copies publishable Markdown into `docs/` for the GitHub Pages site. The repository includes a ready-to-use workflow at `.github/workflows/daily-summary.yml`. + +To use GitHub Pages, enable Pages for the repository and run the scheduled workflow or trigger it manually. The generated site is built from the `docs/` directory. + +## MCP Server + +Horizon includes an MCP server for AI assistants and MCP-compatible clients. + +```bash +uv run horizon-mcp +``` + +Available tools include `hz_validate_config`, `hz_fetch_items`, `hz_score_items`, `hz_filter_items`, `hz_enrich_items`, `hz_generate_summary`, and `hz_run_pipeline`. + +See [`src/mcp/README.md`](../src/mcp/README.md) for the full tool reference and [`src/mcp/integration.md`](../src/mcp/integration.md) for client setup. diff --git a/docs/extractors.md b/docs/extractors.md new file mode 100644 index 0000000..9db0efb --- /dev/null +++ b/docs/extractors.md @@ -0,0 +1,76 @@ +--- +layout: default +title: Content Extractors +--- + +# Content Extractors + +Extractors fetch and parse the full text of a linked article, replacing the brief excerpt a feed normally provides. They are opt-in per RSS source via the `content_extractor` field. + +## How it works + +1. The RSS scraper parses the feed as usual. +2. For each entry whose source has `content_extractor` set, the scraper calls the named extractor with the entry's link URL. +3. If the extractor returns text, it replaces the feed-provided content. If extraction fails for any reason, the feed content is used as a fallback. + +Extractors are defined under the top-level `extractors` section of `config.json` and referenced by name from individual RSS sources. + +## Configuration + +```json +{ + "extractors": { + "full-text": { + "type": "trafilatura", + "favor_precision": true + } + }, + "sources": { + "rss": [ + { + "name": "Simon Willison", + "url": "https://simonwillison.net/atom/everything/", + "content_extractor": "full-text" + } + ] + } +} +``` + +Each entry under `extractors` is a named extractor configuration. The `type` field determines the implementation. An RSS source references an extractor by its name via `content_extractor`. + +Using a type name directly (e.g. `"content_extractor": "trafilatura"`) also works — each type is pre-registered as a default extractor with default settings, so no explicit entry in `extractors` is required for the basic case. + +## Extractor types + +### `trafilatura` + +**File**: `src/extractors/trafilatura.py` + +Uses the [trafilatura](https://trafilatura.readthedocs.io/) library to extract main article text from HTML. Requires the `trafilatura` optional dependency: + +```bash +uv sync --extra trafilatura +``` + +**Config fields**: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `type` | string | `"trafilatura"` | Extractor type identifier | +| `favor_precision` | bool | `false` | Prefer fewer but more relevant results | +| `favor_recall` | bool | `false` | Prefer more results, accepting some noise | + +`favor_precision` and `favor_recall` are mutually exclusive. Setting both has undefined behavior (trafilatura's own default applies). + +**Timeout and retry**: the extractor reuses the shared `httpx.AsyncClient` and inherits whatever timeout and connection settings are configured on it. + +## Adding a new extractor type + +1. Add an entry to `ExtractorType` in `src/models.py`. +2. Add a config class (e.g. `MyExtractorConfig`) with `type: Literal[ExtractorType.MY_TYPE]`. +3. Add the new config to the `ExtractorConfig` union in `src/models.py`. +4. Implement the class in `src/extractors/my_extractor.py`, subclassing `BaseExtractor`. +5. Export it from `src/extractors/__init__.py`. +6. Add a `case MyExtractorConfig()` branch in `_build()` in `src/extractors/registry.py`. +7. Add a default entry to `_DEFAULTS` in `src/extractors/registry.py`. diff --git a/docs/feed-en.xml b/docs/feed-en.xml new file mode 100644 index 0000000..288a909 --- /dev/null +++ b/docs/feed-en.xml @@ -0,0 +1,22 @@ +--- +layout: null +permalink: /feed-en.xml +--- + + + {{ site.title }} - English Digest + + + {{ site.time | date_to_xmlschema }} + {{ '/' | absolute_url }} + {% assign en_posts = site.posts | where: "lang", "en" %} + {% for post in en_posts limit:7 %} + + {{ post.title | escape }} + + {{ post.date | date_to_xmlschema }} + {{ post.url | absolute_url }} + + + {% endfor %} + diff --git a/docs/feed-zh.xml b/docs/feed-zh.xml new file mode 100644 index 0000000..e29455d --- /dev/null +++ b/docs/feed-zh.xml @@ -0,0 +1,22 @@ +--- +layout: null +permalink: /feed-zh.xml +--- + + + {{ site.title }} - 中文摘要 + + + {{ site.time | date_to_xmlschema }} + {{ '/' | absolute_url }} + {% assign zh_posts = site.posts | where: "lang", "zh" %} + {% for post in zh_posts limit:7 %} + + {{ post.title | escape }} + + {{ post.date | date_to_xmlschema }} + {{ post.url | absolute_url }} + + + {% endfor %} + diff --git a/docs/horizon-hub-design.md b/docs/horizon-hub-design.md new file mode 100644 index 0000000..5be86c9 --- /dev/null +++ b/docs/horizon-hub-design.md @@ -0,0 +1,199 @@ +# HorizonHub Product Design Document + +## Positioning + +**One-sentence positioning**: The information source marketplace for the Horizon ecosystem—driven by real community usage data for discovery, recommendation, and quality assessment. + +**Difference from Competitors**: + +| Product | What it does | What it doesn't do | +|---|---|---| +| RSSHub | Turns websites without RSS into RSS (Pipe) | No quality assessment, no recommendations | +| Feedly | RSS Reader with discovery features | No AI filtering, no personalized recommendations | +| HN / Reddit | Community-driven content aggregation | Fixed sources, user cannot customize | +| **HorizonHub** | **Data-driven source recommendation & quality assessment** | **No content hosting, not a reader** | + +**Core Moat**: The daily operation of every Horizon user generates quality data for information sources (AI scores, signal-to-noise ratio, output frequency). When aggregated in the Hub, this data forms a **dynamic quality profile** that no static recommendation list can provide. + +--- + +## System Architecture + +``` +┌──────────────────────────────────────────────────┐ +│ User Local │ +│ │ +│ horizon-wizard (TUI) Horizon CLI │ +│ ┌────────────────┐ ┌────────────────┐ │ +│ │ Browse/Search │ │ Fetch -> AI │ │ +│ │ Add/Remove │──Write─▶│ Score -> │ │ +│ │ Recommend │ │ Gen Summary │ │ +│ └───────┬────────┘ └───────┬────────┘ │ +│ │ │ │ +└──────────┼──────────────────────────┼────────────┘ + │ Report Ops Events │ Report Quality Data + ▼ ▼ +┌──────────────────────────────────────────────────┐ +│ HorizonHub Server │ +│ │ +│ ┌───────────┐ ┌─────────────┐ ┌────────────┐ │ +│ │ Source DB │ │ Rank Engine │ │ Recommender│ │ +│ └───────────┘ └─────────────┘ └────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ Hub Web UI │ │ +│ │ (Market / Rank) │ │ +│ └─────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +Two core components: +- **Hub Server**: Data center + Web frontend, receiving reports, storing statistics, providing APIs and web pages. +- **Local Client (horizon-wizard)**: The sole entry point for users to manage information sources; every operation naturally generates data. + +--- + +## Feature List + +### Source Market (Browse) + +The core interface users see when opening the Hub website. + +**Page Structure**: + +- **Top Dashboard**: A row of statistics cards. + - Total Sources | Field Categories | Contributors | Active Users + +- **Source Card Waterfall**: Each source has a card. + - Source Name + Type Tags (RSS / Reddit / GitHub / Telegram / Twitter) + - Color-coded Field Tags (AI Purple, Systems Blue, Security Red...) + - One-sentence Bio (CN/EN) + - Key Metrics: Users · AI Avg Score · Signal-to-Noise Ratio + - Contributor Avatars + - Badges: 🔥 Hot / ✨ New / ⚠️ Quality Dropped + +- **Filtering and Sorting**: + - Filter by field / language / type + - Sort by Popularity (Users) / Quality (AI Avg) / SNR / Latest Added + - Keyword Search + +### Source Profile + +The detail page for each source, showing a complete data-driven profile. + +**Included Data**: + +| Metric | Description | Data Source | +|---|---|---| +| Active Users | Number of users using this source in the past 30 days | Telemetry | +| AI Avg Score | Average AI score of content produced by this source | Telemetry | +| SNR | Percentage of items passing AI filtering vs. total fetched | Telemetry | +| Avg Daily Output| Average number of items fetched per day | Telemetry | +| Score Trend | Line chart of AI average scores over the last 30 days | Telemetry Aggregation | +| User Trend | Changes in active users over the last 30 days | Telemetry Aggregation | +| Contributor | Who submitted this source | User submission records | +| Date Added | When it was added to the Hub | Submission records | + +### User Submission (Contribute) + +**Submission Process**: + +``` +User (Hub Web or Local Client) + → GitHub OAuth Login + → Fill info: Name, URL, Type, Category, Language, Bio + → Submit + +Hub Server + → Automatically fetch last 10 items from source + → AI quality assessment (Avg score, SNR) + → Quality OK → Auto-online, Status: ✅ Online + → Quality Poor → Mark pending, notify maintainer for manual review +``` + +**Channels**: +- Hub Web Form (most intuitive) +- Local Client Submission (one-click via `horizon-wizard`) + +### Intelligent Recommendation (Recommend) + +**Scenarios**: + +1. **New User Cold Start**: Enter interest keywords ("AI", "Linux Kernel") to recommend the best source combination. +2. **Complementary Recommendation**: Analyze existing config to recommend sources with complementary coverage and flag high-overlap sources. +3. **Collaborative Filtering** (post-scale): "Users with similar tastes also read..." + +**Input for Rec Algorithm**: +- Source field tags +- Content overlap between sources (calculated via deduplication data) +- Usage patterns of user cohorts + +### One-click Export (Export) + +After users select sources on the Hub website: + +- Generate `config.json` snippet → Copy to clipboard +- Download full config file +- Generate `horizon-wizard` command → One-click import via terminal + +### Contributor System (Community) + +**Contributor Leaderboard**: +- Ranked by number of sources contributed. +- Displays GitHub avatar + link + contribution count. + +**Contributor Homepage**: +- Sources I submitted. +- How many people use my sources in total. +- Average quality score of my sources. + +**Badge System**: + +| Badge | Condition | +|---|---| +| 🌱 First Contribution | Submit the first source | +| 🌟 Quality Contributor| Contributed sources have Avg Score ≥ 7.0 | +| 🔥 Popular Contributor| A single source used by ≥ 50 people | +| 👑 Core Contributor | Contributed ≥ 10 sources | + +### Source Health Monitoring + +**Automatic Decay Detection** (Option A — Passive): + +Hub server continuously tracks active user trends for each source. If usage drops continuously (e.g., >30% drop within 30 days), auto-mark with a ⚠️ warning. + +**User Feedback Collection** (Option B — Active): + +When a user deletes or disables a source via `horizon-wizard`, a popup asks for optional feedback: + +``` +You removed "QbitAI", can you tell us why? (Optional, Enter to skip) +1. Quality dropped +2. Too much overlap with other sources +3. Low update frequency / defunct +4. Doesn't match my interests +> +``` + +Reported to the Hub, integrated with decay data for comprehensive judgment. + +--- + +## Distributed Agent Operating System + +### Analogy + +If the Horizon ecosystem is viewed as a **Distributed Agent Operating System**. + +A single Horizon instance is like a "standalone machine" managing one user's information flow. HorizonHub acts as the **Control Plane** that coordinates all users' Agents into a whole, allowing decentralized individual judgments to converge into collective intelligence. + +### Why "Emergence"? + +Each Agent runs independently and is unaware of others, but: +- **Diversity**: Different users subscribe to sources in different fields, naturally providing diverse perspectives. +- **Independence**: Each Agent's AI scoring is unaffected by other users. +- **Aggregation**: The Hub aggregates all scores to form a global quality signal more accurate than any single Agent. + +This is not designed intelligence, but rather consensus **emerging** from a large number of independent judgments—mathematically aligned with the Condorcet Jury Theorem. + +--- diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ed1e872 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,56 @@ +--- +layout: default +title: Home +--- + +# Horizon + +
+ +欢迎来到 [Horizon](https://github.com/thysrael/Horizon),一个 AI 驱动的信息聚合系统。 + +## 文档 + +- [配置指南](configuration) — AI 提供商、信息源、过滤规则与环境变量替换 +- [信息源采集器](scrapers) — Horizon 如何从 GitHub、Hacker News、RSS、Reddit 采集内容 +- [评分系统](scoring) — 基于 AI 的内容分析与 0-10 评分体系 + +## 每日速递 + +
    + {% assign zh_posts = site.posts | where: "lang", "zh" %} + {% for post in zh_posts limit:20 %} +
  • + {{ post.date | date: "%Y-%m-%d" }} +
  • + {% else %} +
  • 暂无内容
  • + {% endfor %} +
+ +
+ +
+ +Welcome to [Horizon](https://github.com/thysrael/Horizon), an AI-driven information aggregation system. + +## Documentation + +- [Configuration Guide](configuration) — AI providers, information sources, filtering, and environment variable substitution +- [Source Scrapers](scrapers) — How Horizon collects content from GitHub, Hacker News, RSS, and Reddit +- [Scoring System](scoring) — AI-based content analysis and the 0-10 scoring scale + +## Daily Digest + +
    + {% assign en_posts = site.posts | where: "lang", "en" %} + {% for post in en_posts limit:20 %} +
  • + {{ post.date | date: "%Y-%m-%d" }} +
  • + {% else %} +
  • No posts yet
  • + {% endfor %} +
+ +
diff --git a/docs/scoring.md b/docs/scoring.md new file mode 100644 index 0000000..508f1e4 --- /dev/null +++ b/docs/scoring.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Scoring System +--- + +# Scoring System + +After fetching content from all sources, Horizon uses an AI model to score each item on a 0-10 scale. This determines what appears in the daily summary. + +## Pipeline + +1. **Batch processing** — Items are scored in batches of 10 with a progress bar. Failed items receive a score of 0. +2. **Content preparation** — For each item, the content is truncated (800 chars if comments are present, 1000 otherwise) and engagement metrics are assembled from metadata (HN score, Reddit upvote ratio, etc.). +3. **AI analysis** — The prepared content is sent to the configured AI model (temperature 0.3) with a system prompt defining the scoring criteria. +4. **Response parsing** — The AI response is parsed as JSON (with fallbacks for code-block-wrapped JSON). Each item gets: `ai_score` (float), `ai_reason` (string), `ai_summary` (string), and `ai_tags` (list). +5. **Retry** — Failed AI calls are retried up to 3 times with exponential backoff (2-10 seconds). + +## Scoring Scale + +| Score | Tier | Description | +|-------|------|-------------| +| 9-10 | Groundbreaking | Major breakthroughs, paradigm shifts, major version releases, significant research breakthroughs | +| 7-8 | High Value | Important developments, technical deep-dives, novel approaches, insightful analysis, valuable tools | +| 5-6 | Interesting | Incremental improvements, useful tutorials, moderate community interest | +| 3-4 | Low Priority | Minor updates, common knowledge, overly promotional | +| 0-2 | Noise | Spam, off-topic, trivial updates | + +## Scoring Factors + +The AI evaluates each item based on: + +- **Technical depth and novelty** — original ideas, new techniques, research contributions +- **Potential impact** — how broadly this affects software engineering, AI/ML, or systems research +- **Quality of writing/presentation** — clarity, structure, thoroughness +- **Community discussion** — insightful comments, diverse viewpoints, substantive debates +- **Engagement signals** — high upvotes/favorites paired with substantive discussion (not just raw numbers) + +Engagement metadata is source-specific: HN provides score and comment count, Reddit provides upvote ratio and comment count. + +## Filtering + +After scoring, items are filtered by `filtering.ai_score_threshold` (default: `7.0`) and sorted by score descending. Optional balanced digest quotas are then applied before enrichment. + +```json +{ + "filtering": { + "ai_score_threshold": 7.0, + "time_window_hours": 24, + "max_items": 20, + "category_groups": { + "ai": { + "limit": 5, + "categories": ["ai-news", "ai-tools", "machine-learning"] + } + } + } +} +``` + +`category_groups` limits each configured category group independently. +`max_items` caps the merged result. Both fields are optional; without them, +scoring and filtering behave as before. + +Items scoring 9.0 or above are featured in the "Today's Highlights" section of the summary. + +## Enrichment + +Items that pass the score threshold and any balanced digest limits go through a second AI pass for enrichment (`src/ai/enricher.py`): + +1. **Concept extraction** — AI identifies 1-3 technical concepts in the item that may need explanation. +2. **Web search** — Each concept is searched via DuckDuckGo to gather grounding context. +3. **Structured analysis** — The item content and search results are sent to AI, which produces: + - `whats_new` — what specifically happened or changed + - `why_it_matters` — significance and impact + - `key_details` — notable technical details or caveats + - `background` — background knowledge for readers without deep domain expertise + +These fields are combined into a `detailed_summary` stored in the item's metadata and used in the final daily summary. diff --git a/docs/scrapers.md b/docs/scrapers.md new file mode 100644 index 0000000..3e2f7ff --- /dev/null +++ b/docs/scrapers.md @@ -0,0 +1,232 @@ +--- +layout: default +title: Source Scrapers +--- + +# Source Scrapers + +Horizon fetches content from multiple source types. All scrapers inherit from `BaseScraper`, share an async HTTP client, and implement a `fetch(since)` method that returns a list of `ContentItem` objects. Sources are fetched concurrently via `asyncio.gather`. + +## Hacker News + +**File**: `src/scrapers/hackernews.py` + +Uses the [Firebase HN API](https://hacker-news.firebaseio.com/v0): + +- `GET /topstories.json` — fetches top story IDs +- `GET /item/{id}.json` — fetches story/comment details + +Stories and their comments are fetched concurrently. For each story, the top 5 comments are included (deleted/dead comments excluded, HTML stripped, truncated at 500 chars). + +**Config** (`sources.hackernews`): + +```json +{ + "enabled": true, + "fetch_top_stories": 30, + "min_score": 100, + "category": "tech" +} +``` + +- `fetch_top_stories` — number of top story IDs to fetch +- `min_score` — minimum HN points to include a story +- `category` — optional tag for balanced digest grouping + +**Extracted data**: title, URL (falls back to HN discussion URL), author, score, comment count, top comment text, and category. + +## GitHub + +**File**: `src/scrapers/github.py` + +Uses the [GitHub REST API](https://api.github.com): + +- `GET /users/{username}/events/public` — user activity events +- `GET /repos/{owner}/{repo}/releases` — repository releases + +Two source types are supported: + +- **`user_events`** — tracks push, create, release, public, and watch events for a user +- **`repo_releases`** — tracks new releases for a specific repository + +**Config** (`sources.github`, list of entries): + +```json +{ + "type": "user_events", + "username": "torvalds", + "enabled": true, + "category": "oss" +} +``` + +```json +{ + "type": "repo_releases", + "owner": "golang", + "repo": "go", + "enabled": true, + "category": "oss" +} +``` + +- `category` — optional tag for balanced digest grouping; set per source entry + +**Authentication**: Set `GITHUB_TOKEN` in your environment for higher rate limits (5000 req/hr vs 60 without). + +## RSS + +**File**: `src/scrapers/rss.py` + +Fetches any Atom/RSS feed using the `feedparser` library. Tries multiple date fields (`published`, `updated`, `created`) with fallback parsing. + +**Config** (`sources.rss`, list of entries): + +```json +{ + "name": "Simon Willison", + "url": "https://simonwillison.net/atom/everything/", + "enabled": true, + "category": "ai-tools", + "content_extractor": "trafilatura" +} +``` + +- `category` — optional tag for grouping (e.g., `"programming"`, `"microblog"`) +- `content_extractor` — optional name of an extractor defined in `extractors` config; when set, the full article text replaces the feed-provided excerpt (see [Extractors](extractors.md)) + +**Extracted data**: title, URL, author, content (from `summary`/`description`/`content` fields, or full article text if an extractor is configured), feed name, category, and entry tags. + +## Reddit + +**File**: `src/scrapers/reddit.py` + +Uses public, no-key Reddit endpoints. Subreddit listings and comments prefer `old.reddit.com` HTML because Reddit's unauthenticated JSON and RSS endpoints can intermittently block or fail: + +- `GET https://old.reddit.com/r/{subreddit}/{sort}/` — subreddit posts +- `GET https://old.reddit.com/r/{subreddit}/comments/{post_id}/` — post comments +- `GET /r/{subreddit}/{sort}.json` — subreddit posts fallback +- `GET /user/{username}/submitted.json` — user submissions +- `GET /r/{subreddit}/comments/{post_id}.json` — post comments fallback +- `GET /r/{subreddit}/{sort}/.rss` — subreddit posts fallback when JSON is blocked + +Subreddits and users are fetched concurrently. Comments are sorted by score, limited to the configured count, and exclude moderator-distinguished comments. Self-text is truncated at 1500 chars, comments at 500 chars. + +**Config** (`sources.reddit`): + +```json +{ + "enabled": true, + "fetch_comments": 5, + "subreddits": [ + { + "subreddit": "MachineLearning", + "sort": "hot", + "fetch_limit": 25, + "min_score": 10, + "category": "ai-ml" + } + ], + "users": [ + { + "username": "spez", + "sort": "new", + "fetch_limit": 10, + "category": "social" + } + ] +} +``` + +- `sort` — `hot`, `new`, `top`, or `rising` (subreddits); `hot` or `new` (users) +- `time_filter` — for `top`/`rising` sorts: `hour`, `day`, `week`, `month`, `year`, `all` +- `min_score` — minimum post score (subreddits only) +- `category` — optional tag for balanced digest grouping; set per subreddit or per user entry + +**Rate limiting**: Detects HTTP 429 responses on JSON requests, reads the `Retry-After` header, waits, and retries once. Uses browser-like request headers for no-key public access. + +**Extracted data**: title, URL, author, score, upvote ratio, comment count, subreddit, flair, self-text, top comments, and category. + +## OpenBB + +**File**: `src/scrapers/openbb.py` + +Uses the [OpenBB Platform](https://www.openbb.co/platform) Python SDK via `obb.news.company()` to fetch company news for one or more ticker watchlists. + +The scraper imports `openbb` lazily. If the optional dependency is not installed, Horizon logs a warning and skips the source instead of failing the whole run. + +**Config** (`sources.openbb`): + +```json +{ + "enabled": true, + "watchlists": [ + { + "name": "megacaps", + "symbols": ["AAPL", "MSFT", "NVDA"], + "enabled": true, + "provider": "yfinance", + "fetch_limit": 20, + "category": "equities" + } + ] +} +``` + +- `watchlists` — each enabled watchlist triggers one `news.company()` call per run +- `provider` — OpenBB provider name for that watchlist +- `symbols` — tickers fetched together for the same provider +- `fetch_limit` — maximum rows requested from the provider +- `category` — optional metadata tag stored on each item + +Behavior: + +- Wraps the synchronous OpenBB SDK in `asyncio.to_thread` so the event loop stays responsive +- Deduplicates duplicate news across watchlists by article URL +- Skips malformed rows, rows without URL/title/date, and items older than the current time window +- Keeps fetching other watchlists if one provider call fails + +**Credentials**: provider-specific secrets are resolved by the OpenBB SDK from its own environment variables or settings file. Horizon does not pass those values directly. + +**Extracted data**: title, URL, author, published time, article body/excerpt, watchlist name, provider, category, and symbol list. + +## Twitter + +**File**: `src/scrapers/twitter.py` + +Uses the [Apify](https://apify.com) platform to bypass Twitter's anti-scraping measures. The actor `altimis~scweet` is called via the Apify REST API. + +Flow: +1. POST to `/v2/acts/{actor_id}/runs` to trigger a run +2. Poll `/v2/actor-runs/{run_id}` until status is `SUCCEEDED` or a terminal failure +3. GET `/v2/datasets/{dataset_id}/items` to retrieve results + +**Config** (`sources.twitter`): + +```json +{ + "enabled": true, + "users": ["karpathy", "ylecun"], + "fetch_limit": 10, + "fetch_reply_text": false, + "max_replies_per_tweet": 3, + "max_tweets_to_expand": 10, + "reply_min_likes": 5, + "actor_id": "altimis~scweet", + "apify_token_env": "APIFY_TOKEN" +} +``` + +- `users` — Twitter screen names to monitor, without the `@` prefix +- `fetch_limit` — maximum tweets to fetch per run +- `category` — optional tag for balanced digest grouping (applies to all tweets from this source) +- `fetch_reply_text` — when `true`, a second Apify run fetches reply bodies for each important tweet and appends them under `--- Top Comments ---` for AI analysis +- `max_replies_per_tweet` — maximum reply lines per tweet (sorted by engagement score) +- `max_tweets_to_expand` — cap on reply expansion runs per pipeline cycle, to control Apify credit usage +- `reply_min_likes` — minimum likes required for a reply to be included +- `actor_id` — Apify actor ID (default: `altimis~scweet`) +- `apify_token_env` — environment variable name containing the Apify API token + +**Authentication**: Set `APIFY_TOKEN` in your `.env`. Get a token at [console.apify.com](https://console.apify.com/account/integrations). + +**Extracted data**: tweet text, URL, author, publish time, likes, retweets, replies, views, category, and (optionally) reply-thread text appended under `--- Top Comments ---`. diff --git a/docs/twitter-cookies.md b/docs/twitter-cookies.md new file mode 100644 index 0000000..fd12edb --- /dev/null +++ b/docs/twitter-cookies.md @@ -0,0 +1,128 @@ +# Twitter / X Cookie 配置指南(Playwright 模式) + +Horizon 支持两种 Twitter 抓取方式: + +| 方式 | 成本 | 稳定性 | 适用场景 | +|------|------|--------|----------| +| **Apify** (默认) | 需订阅 ($49/月起) | ⭐⭐⭐ 高 | 生产环境、大量账号 | +| **Playwright + Cookie** (免费) | 免费 | ⭐⭐ 中 | 个人使用、少量账号 | + +本文档介绍免费方案的配置方法。 + +--- + +## 1. 安装依赖 + +```bash +uv sync --extra twitter +uv run playwright install chromium +``` + +--- + +## 2. 获取 Cookie + +### 方法一:浏览器扩展(推荐) + +1. 用 Chrome/Edge/Firefox 登录 [x.com](https://x.com) +2. 安装扩展 **"Cookie-Editor"** 或 **"Get cookies.txt LOCALLY"** +3. 在 x.com 页面打开扩展,导出为 **JSON 格式** +4. 保存到 `data/x_cookies_1.json` + +### 方法二:开发者工具 + +1. 登录 x.com 后按 `F12` 打开开发者工具 +2. 切换到 **Application (应用)** → **Cookies** → `https://x.com` +3. 找到以下关键 Cookie(名称可能略有不同): + - `auth_token` + - `ct0` + - `twid` +4. 手动构建 JSON 数组: + +```json +[ + { + "name": "auth_token", + "value": "你的auth_token值", + "domain": ".x.com", + "path": "/", + "secure": true, + "httpOnly": true + }, + { + "name": "ct0", + "value": "你的ct0值", + "domain": ".x.com", + "path": "/", + "secure": true, + "httpOnly": false + } +] +``` + +--- + +## 3. 配置文件 + +编辑 `data/config.json`: + +```json +{ + "sources": { + "twitter": { + "enabled": true, + "mode": "playwright", + "users": ["karpathy", "ylecun"], + "fetch_limit": 10, + "cookie_dir": "data", + "cookie_file_pattern": "x_cookies_*.json" + } + } +} +``` + +--- + +## 4. 多账号轮询(防封策略) + +如果你有多个 X 账号,可以为每个账号导出 cookie,命名为: + +``` +data/x_cookies_1.json # 账号A +data/x_cookies_2.json # 账号B +data/x_cookies_3.json # 账号C +``` + +Horizon 会自动**轮询使用**这些 cookie,当一个账号触发限流时切换到下一个,大幅提升稳定性。 + +--- + +## 5. 代理配置(可选) + +如果在中国大陆或需要代理: + +```bash +export https_proxy=http://127.0.0.1:7890 +``` + +Playwright 会自动读取 `PROXY`、`https_proxy`、`http_proxy`、`all_proxy` 环境变量。 + +--- + +## 6. 注意事项 + +⚠️ **Cookie 有有效期**:通常 1-4 周,过期后需要重新导出 +⚠️ **不要提交 Cookie 文件**:已加入 `.gitignore`,请妥善保管 +⚠️ **账号安全**:建议使用**小号/备用号**,避免主账号风险 +⚠️ **抓取频率**:每个账号间隔 5-10 秒,避免触发平台限流 + +--- + +## 7. 故障排除 + +| 问题 | 原因 | 解决 | +|------|------|------| +| "No cookie files found" | cookie 文件名不匹配 | 检查 `cookie_file_pattern` 和实际文件名 | +| "page shows login gate" | cookie 已过期 | 重新登录并导出最新 cookie | +| "no GraphQL data intercepted" | 页面结构变化或被限流 | 等待几分钟后重试,或增加 cookie 数量 | +| Playwright 未安装 | 依赖缺失 | 运行 `uv sync --extra twitter && uv run playwright install chromium` | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5265171 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[project] +name = "horizon" +version = "0.1.0" +description = "AI-driven information aggregation system for tracking academic and social trends" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27.0", + "feedparser>=6.0.11", + "anthropic>=0.39.0", + "openai>=1.54.0", + "google-genai>=0.3.0", + "pydantic>=2.9.0", + "python-dateutil>=2.9.0", + "rich>=13.9.0", + "tenacity>=9.0.0", + "python-dotenv>=1.0.0", + "ddgs>=7.0.0", + "beautifulsoup4>=4.12.0", + "markdown>=3.10.2", + "mcp>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=5.0.0", +] +openbb = [ + "openbb>=4.4.0", + "openbb-benzinga>=1.6.0", +] +twitter = [ + "playwright>=1.40.0", + "playwright-stealth>=1.0.0", +] +trafilatura = [ + "trafilatura>=2.1.0", +] + +[project.scripts] +horizon = "src.main:main" +horizon-mcp = "src.mcp.server:main" +horizon-wizard = "src.setup.wizard:main" +horizon-webhook = "src.services.webhook_cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-q" +testpaths = ["tests"] diff --git a/scripts/check_mcp.py b/scripts/check_mcp.py new file mode 100644 index 0000000..90499f0 --- /dev/null +++ b/scripts/check_mcp.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Local smoke check for Horizon MCP integration.""" + +from __future__ import annotations + +import asyncio +import json + +from src.mcp.horizon_adapter import resolve_horizon_path +from src.mcp.server import hz_get_metrics +from src.mcp.service import HorizonPipelineService + + +async def _main() -> None: + horizon_path = resolve_horizon_path() + service = HorizonPipelineService() + validation = await service.validate_config( + horizon_path=str(horizon_path), + check_env=False, + ) + metrics = hz_get_metrics() + + payload = { + "ok": True, + "horizon_path": str(horizon_path), + "config_path": validation["config_path"], + "enabled_sources": validation["enabled_sources"], + "languages": validation["ai"]["languages"], + "metrics_ok": metrics["ok"], + "metrics_tool": metrics["tool"], + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/scripts/daily-run.sh b/scripts/daily-run.sh new file mode 100755 index 0000000..0c6427f --- /dev/null +++ b/scripts/daily-run.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Horizon daily run + deploy to GitHub Pages +# Usage: ./scripts/daily-run.sh +# Cron: 0 8 * * * /path/to/horizon/scripts/daily-run.sh >> /path/to/horizon/logs/cron.log 2>&1 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')]" + +cd "$PROJECT_DIR" + +echo "$LOG_PREFIX Starting Horizon daily run..." + +# 1. Pull latest code +git pull --quiet origin main + +# 2. Install/update dependencies +uv sync --quiet + +# 3. Run Horizon +uv run horizon --hours 24 + +# 4. Deploy docs to gh-pages +echo "$LOG_PREFIX Deploying to gh-pages..." + +# Use a temporary worktree to update gh-pages without switching branches +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +git fetch origin gh-pages:gh-pages 2>/dev/null || git checkout --orphan gh-pages && git checkout main + +git worktree add "$TMPDIR" gh-pages +cp -r docs/* "$TMPDIR/" + +cd "$TMPDIR" +git add -A +git commit -m "Daily Summary: $(date '+%Y-%m-%d')" || { echo "$LOG_PREFIX Nothing to commit."; exit 0; } +git push origin gh-pages + +cd "$PROJECT_DIR" +git worktree remove "$TMPDIR" + +echo "$LOG_PREFIX Done." diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ai/__init__.py b/src/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ai/analyzer.py b/src/ai/analyzer.py new file mode 100644 index 0000000..3de5068 --- /dev/null +++ b/src/ai/analyzer.py @@ -0,0 +1,162 @@ +"""Content analysis using AI.""" + +import asyncio +import json +import re +from typing import List, Optional +from tenacity import retry, stop_after_attempt, wait_exponential +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, MofNCompleteColumn + +from .client import AIClient +from .prompts import CONTENT_ANALYSIS_SYSTEM, CONTENT_ANALYSIS_USER +from .utils import parse_json_response +from ..models import ContentItem + +DEFAULT_THROTTLE_SEC = 0.0 + + +class ContentAnalyzer: + """Analyzes content items using AI to determine importance.""" + + def __init__(self, ai_client: AIClient): + self.client = ai_client + + @staticmethod + def _parse_json_response(response: str) -> Optional[dict]: + """Try multiple strategies to extract a JSON object from an AI response. + + Returns the parsed dict, or None if all strategies fail. + """ + return parse_json_response(response) + + def _get_throttle_sec(self) -> float: + """Return the configured inter-item throttle, clamped to zero or above.""" + config = getattr(self.client, "config", None) + throttle_sec = getattr(config, "throttle_sec", DEFAULT_THROTTLE_SEC) + return max(throttle_sec, 0.0) + + def _get_concurrency(self) -> int: + """Return the configured analysis concurrency, clamped to 1 or above.""" + config = getattr(self.client, "config", None) + concurrency = getattr(config, "analysis_concurrency", 1) + return max(concurrency, 1) + + async def analyze_batch(self, items: List[ContentItem]) -> List[ContentItem]: + throttle_sec = self._get_throttle_sec() + concurrency = self._get_concurrency() + semaphore = asyncio.Semaphore(concurrency) + + async def _process(item: ContentItem, index: int, progress_task) -> ContentItem: + async with semaphore: + try: + await self._analyze_item(item) + except Exception as e: + print(f"Error analyzing item {item.id}: {e}") + item.ai_score = 0.0 + item.ai_reason = "Analysis failed" + item.ai_summary = item.title + if throttle_sec > 0 and index < len(items) - 1: + await asyncio.sleep(throttle_sec) + progress.advance(progress_task) + return item + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + transient=True, + ) as progress: + task = progress.add_task("Analyzing", total=len(items)) + coros = [ + _process(item, i, task) for i, item in enumerate(items) + ] + analyzed_items = await asyncio.gather(*coros) + + return analyzed_items + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(min=2, max=10) + ) + async def _analyze_item(self, item: ContentItem) -> None: + """Analyze a single content item. + + Args: + item: Content item to analyze (modified in-place) + """ + # Prepare content section + content_section = "" + if item.content: + # Split off comments if present + content_text = item.content + if "--- Top Comments ---" in content_text: + main, comments_part = content_text.split("--- Top Comments ---", 1) + content_section = f"Content: {main.strip()[:800]}" + else: + content_section = f"Content: {content_text[:1000]}" + + # Prepare discussion section (comments, engagement) + discussion_parts = [] + if item.content and "--- Top Comments ---" in item.content: + comments_part = item.content.split("--- Top Comments ---", 1)[1] + discussion_parts.append(f"Community Comments:\n{comments_part[:1500]}") + + meta = item.metadata + engagement_items = [] + if meta.get("score"): + engagement_items.append(f"score: {meta['score']}") + if meta.get("descendants"): + engagement_items.append(f"{meta['descendants']} comments") + if meta.get("favorite_count"): + engagement_items.append(f"{meta['favorite_count']} likes") + if meta.get("retweet_count"): + engagement_items.append(f"{meta['retweet_count']} retweets") + if meta.get("reply_count"): + engagement_items.append(f"{meta['reply_count']} replies") + if meta.get("views"): + engagement_items.append(f"{meta['views']} views") + if meta.get("bookmarks"): + engagement_items.append(f"{meta['bookmarks']} bookmarks") + if meta.get("upvote_ratio"): + engagement_items.append(f"upvote ratio: {meta['upvote_ratio']:.0%}") + if engagement_items: + discussion_parts.append(f"Engagement: {', '.join(engagement_items)}") + if meta.get("discussion_url"): + discussion_parts.append(f"Discussion: {meta['discussion_url']}") + if meta.get("community_note"): + discussion_parts.append(f"Community Note: {meta['community_note']}") + + discussion_section = "\n".join(discussion_parts) if discussion_parts else "" + + # Generate user prompt + user_prompt = CONTENT_ANALYSIS_USER.format( + title=item.title, + source=f"{item.source_type.value}", + author=item.author or "Unknown", + url=str(item.url), + content_section=content_section, + discussion_section=discussion_section + ) + + # Get AI completion + response = await self.client.complete( + system=CONTENT_ANALYSIS_SYSTEM, + user=user_prompt, + ) + + # Parse JSON response with robust fallback + result = self._parse_json_response(response) + if result is None: + print(f"Warning: could not parse analysis response for {item.id}, using defaults") + item.ai_score = 0.0 + item.ai_reason = "Analysis response parse failed" + item.ai_summary = item.title + item.ai_tags = [] + return + + # Update item with analysis results + item.ai_score = float(result.get("score", 0)) + item.ai_reason = result.get("reason", "") + item.ai_summary = result.get("summary", item.title) + item.ai_tags = result.get("tags", []) diff --git a/src/ai/client.py b/src/ai/client.py new file mode 100644 index 0000000..07f4adc --- /dev/null +++ b/src/ai/client.py @@ -0,0 +1,678 @@ +"""AI client abstraction supporting multiple providers.""" + +import os +import re +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional +from openai import AsyncAzureOpenAI, AsyncOpenAI +from anthropic import AsyncAnthropic +from google import genai +from google.genai import types + + +from ..models import AIConfig, AIProvider +from rich import print as rich_print +from .tokens import record_usage + + +_ENV_VAR_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_SECRET_PREFIXES = ( + "sk-", + "sk_", + "AIza", + "xai-", + "gsk_", + "hf_", +) +_DEFAULT_API_KEY_ENVS = { + AIProvider.ANTHROPIC: "ANTHROPIC_API_KEY", + AIProvider.OPENAI: "OPENAI_API_KEY", + AIProvider.AZURE: "AZURE_OPENAI_API_KEY", + AIProvider.ALI: "DASHSCOPE_API_KEY", + AIProvider.GEMINI: "GOOGLE_API_KEY", + AIProvider.DOUBAO: "DOUBAO_API_KEY", + AIProvider.MINIMAX: "MINIMAX_API_KEY", + AIProvider.DEEPSEEK: "DEEPSEEK_API_KEY", +} + + +def _resolve_api_key(config: AIConfig, *, fallback: Optional[str] = None) -> str: + api_key = os.getenv(config.api_key_env) + if api_key: + return api_key + if fallback is not None: + return fallback + raise ValueError(_missing_api_key_message(config)) + + +def _missing_api_key_message(config: AIConfig) -> str: + expected_env = _DEFAULT_API_KEY_ENVS.get(config.provider) + if expected_env: + setup_hint = ( + f"Set {expected_env}=your_api_key in .env or your shell, then set " + f'ai.api_key_env to "{expected_env}" in data/config.json.' + ) + else: + setup_hint = ( + "Set the provider API key in .env or your shell, then set " + "ai.api_key_env to that environment variable name in data/config.json." + ) + + if _looks_like_api_key_value(config.api_key_env): + return ( + "Missing API key: ai.api_key_env must be an environment variable " + f"name, not the API key value. {setup_hint}" + ) + + return ( + "Missing API key environment variable configured by ai.api_key_env. " + "ai.api_key_env should contain the environment variable name, not the " + f"key value. {setup_hint}" + ) + + +def _looks_like_api_key_value(value: str) -> bool: + if value.startswith(_SECRET_PREFIXES): + return True + return not bool(_ENV_VAR_RE.fullmatch(value)) + + +def _normalize_ollama_base_url(base_url: str) -> str: + normalized = base_url.strip().rstrip("/") + if "://" not in normalized: + normalized = f"http://{normalized}" + if normalized.endswith("/v1"): + return normalized + return f"{normalized}/v1" + + +class AIClient(ABC): + """Abstract base class for AI clients.""" + + @abstractmethod + async def complete( + self, + system: str, + user: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> str: + """Generate completion from AI model. + + Args: + system: System prompt + user: User prompt + temperature: Optional sampling temperature override + max_tokens: Optional maximum tokens override + + Returns: + str: Generated completion text + """ + pass + + +class AnthropicClient(AIClient): + """Client for Anthropic Claude models.""" + + def __init__(self, config: AIConfig): + """Initialize Anthropic client. + + Args: + config: AI configuration + """ + self.config = config + + api_key = _resolve_api_key(config) + + kwargs = {"api_key": api_key} + if config.base_url: + kwargs["base_url"] = config.base_url + + self.client = AsyncAnthropic(**kwargs) + self.model = config.model + self.temperature = config.temperature + self.max_tokens = config.max_tokens + + async def complete( + self, + system: str, + user: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> str: + """Generate completion using Claude. + + Args: + system: System prompt + user: User prompt + temperature: Sampling temperature + max_tokens: Maximum tokens to generate + + Returns: + str: Generated text + """ + temperature = self.temperature if temperature is None else temperature + max_tokens = self.max_tokens if max_tokens is None else max_tokens + + message = await self.client.messages.create( + model=self.model, + max_tokens=max_tokens, + temperature=temperature, + system=system, + messages=[{"role": "user", "content": user}] + ) + usage = getattr(message, "usage", None) + if usage is not None: + record_usage( + "anthropic", + input_tokens=getattr(usage, "input_tokens", 0), + output_tokens=getattr(usage, "output_tokens", 0), + ) + return message.content[0].text + + +class OpenAIClient(AIClient): + """Client for OpenAI-compatible APIs.""" + + # Default base URLs per provider + _DEFAULT_BASE_URLS = { + "ali": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "deepseek": "https://api.deepseek.com", + "doubao": "https://ark.cn-beijing.volces.com/api/v3", + "minimax": "https://api.minimax.io/v1", + "ollama": "http://localhost:11434/v1", + } + + _BASE_URL_ENVS = { + "ollama": ( + "HORIZON_OLLAMA_BASE_URL", + "OLLAMA_BASE_URL", + "OLLAMA_HOST", + ), + } + + # Providers that don't support response_format + _NO_RESPONSE_FORMAT = {"minimax"} + + # Providers that need temperature clamped to (0, 1] + _TEMP_CLAMP = {"minimax"} + + # Newer reasoning-series / GPT-5 family models reject legacy `max_tokens` + # and require `max_completion_tokens` instead. + _MODELS_REQUIRING_MAX_COMPLETION_TOKENS = ("o1", "o3", "o4", "gpt-5") + + def __init__(self, config: AIConfig): + """Initialize OpenAI-compatible client. + + Args: + config: AI configuration + """ + self.config = config + + fallback = "no_key" if config.provider == AIProvider.OLLAMA else None + api_key = _resolve_api_key(config, fallback=fallback) + + kwargs = {"api_key": api_key} + base_url = self._resolve_base_url(config) + if base_url: + kwargs["base_url"] = base_url + + self.client = AsyncOpenAI(**kwargs) + self.model = config.model + self.temperature = config.temperature + self.max_tokens = config.max_tokens + self.provider = config.provider.value + # Some newer models (e.g. Claude Opus 4.7 on Bedrock Converse) reject + # `temperature`. We learn this on first 400 and stop sending it. + self._supports_temperature = True + self._use_max_completion_tokens = any( + config.model.startswith(prefix) + for prefix in self._MODELS_REQUIRING_MAX_COMPLETION_TOKENS + ) + + @classmethod + def _resolve_base_url(cls, config: AIConfig) -> Optional[str]: + base_url = (config.base_url or "").strip() + if not base_url: + for env_name in cls._BASE_URL_ENVS.get(config.provider.value, ()): + base_url = os.getenv(env_name, "").strip() + if base_url: + break + if not base_url: + base_url = cls._DEFAULT_BASE_URLS.get(config.provider.value, "") + + if config.provider == AIProvider.OLLAMA and base_url: + return _normalize_ollama_base_url(base_url) + return base_url or None + + async def complete( + self, + system: str, + user: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> str: + """Generate completion using OpenAI-compatible API. + + Args: + system: System prompt + user: User prompt + temperature: Sampling temperature + max_tokens: Maximum tokens to generate + + Returns: + str: Generated text + """ + temperature = self.temperature if temperature is None else temperature + max_tokens = self.max_tokens if max_tokens is None else max_tokens + + # Clamp temperature for providers that require it + if self.provider in self._TEMP_CLAMP and temperature <= 0: + temperature = 0.01 + + try: + response = await self._do_request( + system=system, + user=user, + temperature=temperature, + max_tokens=max_tokens, + include_temperature=self._supports_temperature, + use_max_completion_tokens=self._use_max_completion_tokens, + ) + except Exception as exc: + if self._supports_temperature and self._is_temperature_unsupported(str(exc)): + self._supports_temperature = False + response = await self._do_request( + system=system, + user=user, + temperature=temperature, + max_tokens=max_tokens, + include_temperature=False, + use_max_completion_tokens=self._use_max_completion_tokens, + ) + elif not self._use_max_completion_tokens and self._is_max_tokens_unsupported(str(exc)): + self._use_max_completion_tokens = True + response = await self._do_request( + system=system, + user=user, + temperature=temperature, + max_tokens=max_tokens, + include_temperature=self._supports_temperature, + use_max_completion_tokens=True, + ) + else: + raise + usage = getattr(response, "usage", None) + if usage is not None: + record_usage( + self.provider, + input_tokens=getattr(usage, "prompt_tokens", 0), + output_tokens=getattr(usage, "completion_tokens", 0), + ) + return response.choices[0].message.content + + async def _do_request( + self, + *, + system: str, + user: str, + temperature: float, + max_tokens: int, + include_temperature: bool, + use_max_completion_tokens: bool, + ): + request_kwargs = { + "model": self.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + token_param = "max_completion_tokens" if use_max_completion_tokens else "max_tokens" + request_kwargs[token_param] = max_tokens + if include_temperature: + request_kwargs["temperature"] = temperature + if self.provider not in self._NO_RESPONSE_FORMAT: + request_kwargs["response_format"] = {"type": "json_object"} + return await self.client.chat.completions.create(**request_kwargs) + + @staticmethod + def _is_temperature_unsupported(message: str) -> bool: + lowered = message.lower() + return "temperature" in lowered and ( + "deprecated" in lowered + or "not support" in lowered + or "unsupported" in lowered + ) + + @staticmethod + def _is_max_tokens_unsupported(message: str) -> bool: + lowered = message.lower() + return "max_tokens" in lowered and "max_completion_tokens" in lowered + + +class AzureOpenAIClient(AIClient): + """Client for Azure OpenAI deployments. + + Uses the native AsyncAzureOpenAI client, which requires the deployment + name (passed as `model`), azure_endpoint (resource base URL), and + api_version. The deployment path is assembled internally by the SDK. + """ + + # Newer reasoning-series models reject legacy `max_tokens` and require + # `max_completion_tokens` instead. Azure uses deployment names as `model`, + # so a best-effort guess can be wrong for custom deployment aliases. + _MODELS_REQUIRING_MAX_COMPLETION_TOKENS = ("o1", "o3", "o4", "gpt-5") + + def __init__(self, config: AIConfig): + """Initialize Azure OpenAI client. + + Args: + config: AI configuration + """ + self.config = config + + api_key = _resolve_api_key(config) + if not config.azure_endpoint_env: + raise ValueError("azure_endpoint_env is required for azure provider") + azure_endpoint = os.getenv(config.azure_endpoint_env) + if not azure_endpoint: + raise ValueError(f"Missing Azure endpoint: {config.azure_endpoint_env}") + if not config.api_version: + raise ValueError("api_version is required for azure provider") + + self.client = AsyncAzureOpenAI( + api_key=api_key, + azure_endpoint=azure_endpoint, + api_version=config.api_version, + ) + self.model = config.model + self.temperature = config.temperature + self.max_tokens = config.max_tokens + self._use_max_completion_tokens = any( + config.model.startswith(prefix) + for prefix in self._MODELS_REQUIRING_MAX_COMPLETION_TOKENS + ) + + async def complete( + self, + system: str, + user: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> str: + """Generate completion using Azure OpenAI. + + Args: + system: System prompt + user: User prompt + temperature: Sampling temperature + max_tokens: Maximum tokens to generate + + Returns: + str: Generated text + """ + temperature = self.temperature if temperature is None else temperature + max_tokens = self.max_tokens if max_tokens is None else max_tokens + + try: + response = await self._create_completion( + system=system, + user=user, + temperature=temperature, + max_tokens=max_tokens, + use_max_completion_tokens=self._use_max_completion_tokens, + ) + except Exception as exc: + fallback = self._token_fallback_mode(str(exc)) + if fallback is None: + raise + + self._use_max_completion_tokens = fallback + response = await self._create_completion( + system=system, + user=user, + temperature=temperature, + max_tokens=max_tokens, + use_max_completion_tokens=fallback, + ) + + usage = getattr(response, "usage", None) + if usage is not None: + record_usage( + "openai", + input_tokens=getattr(usage, "prompt_tokens", 0), + output_tokens=getattr(usage, "completion_tokens", 0), + ) + return response.choices[0].message.content + + async def _create_completion( + self, + *, + system: str, + user: str, + temperature: float, + max_tokens: int, + use_max_completion_tokens: bool, + ): + tokens_kwarg = ( + {"max_completion_tokens": max_tokens} + if use_max_completion_tokens + else {"max_tokens": max_tokens} + ) + return await self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=temperature, + response_format={"type": "json_object"}, + **tokens_kwarg, + ) + + @staticmethod + def _token_fallback_mode(message: str) -> Optional[bool]: + lowered = message.lower() + if "max_completion_tokens" in lowered and "max_tokens" in lowered: + return True + if "max_tokens" in lowered and "max_completion_tokens" not in lowered: + return False + return None + + +class GeminiClient(AIClient): + """Client for Google Gemini models.""" + + def __init__(self, config: AIConfig): + """Initialize Gemini client. + + Args: + config: AI configuration + """ + self.config = config + + api_key = _resolve_api_key(config) + + self.client = genai.Client(api_key=api_key) + self.model = config.model + self.temperature = config.temperature + self.max_tokens = config.max_tokens + + async def complete( + self, + system: str, + user: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> str: + """Generate completion using Gemini. + + Args: + system: System prompt + user: User prompt + temperature: Sampling temperature + max_tokens: Maximum tokens to generate + + Returns: + str: Generated text + """ + temperature = self.temperature if temperature is None else temperature + max_tokens = self.max_tokens if max_tokens is None else max_tokens + + response = await self.client.aio.models.generate_content( + model=self.model, + contents=user, + config=types.GenerateContentConfig( + system_instruction=system, + temperature=temperature, + max_output_tokens=max_tokens, + response_mime_type="application/json" + ) + ) + usage = getattr(response, "usage_metadata", None) + if usage is not None: + total = getattr(usage, "total_token_count", 0) or 0 + prompt = getattr(usage, "prompt_token_count", 0) or 0 + completion = max(0, total - prompt) + record_usage("gemini", input_tokens=prompt, output_tokens=completion) + return response.text + + +def _create_single_client(config: AIConfig) -> AIClient: + """Create a single AI client instance.""" + if config.provider == AIProvider.ANTHROPIC: + return AnthropicClient(config) + elif config.provider == AIProvider.AZURE: + return AzureOpenAIClient(config) + elif config.provider == AIProvider.GEMINI: + return GeminiClient(config) + elif config.provider in { + AIProvider.OPENAI, + AIProvider.ALI, + AIProvider.DOUBAO, + AIProvider.MINIMAX, + AIProvider.DEEPSEEK, + AIProvider.OLLAMA, + }: + return OpenAIClient(config) + else: + raise ValueError(f"Unsupported AI provider: {config.provider}") + + +class ChainedAIClient(AIClient): + """Chain multiple AI clients with automatic fallback. + + When a provider fails with a retryable error (rate limit, auth/quota, + service unavailable, or empty response), automatically falls back to + the next provider in the chain. + + Clients are created lazily so that missing API keys for downstream + providers do not block startup when the primary provider works. + """ + + def __init__( + self, + configs: List[AIConfig], + clients: Optional[List[AIClient]] = None, + client_factory: Optional[Any] = None, + ): + self.configs = configs + self._client_factory = client_factory or _create_single_client + self._client_cache: Dict[int, AIClient] = {} + # Allow tests to inject pre-built clients directly + if clients is not None: + for idx, client in enumerate(clients): + self._client_cache[idx] = client + + def _get_client(self, index: int) -> AIClient: + if index not in self._client_cache: + self._client_cache[index] = self._client_factory(self.configs[index]) + return self._client_cache[index] + + async def complete( + self, + system: str, + user: str, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> str: + last_error: Optional[Exception] = None + for i in range(len(self.configs)): + try: + client = self._get_client(i) + result = await client.complete(system, user, temperature, max_tokens) + if not result or not result.strip(): + raise ValueError("Empty response from provider") + return result + except Exception as exc: + if not self._should_fallback(exc): + raise + last_error = exc + if i < len(self.configs) - 1: + rich_print( + f"\n[yellow]Provider {self.configs[i].provider.value} failed ({exc}), " + f"falling back to {self.configs[i + 1].provider.value}...[/yellow]" + ) + raise RuntimeError(f"All providers failed. Last error: {last_error}") + + @staticmethod + def _should_fallback(exc: Exception) -> bool: + """Determine if an error warrants fallback to the next provider.""" + msg = str(exc).lower() + if "429" in msg or "rate limit" in msg: + return True + if "401" in msg or "403" in msg or "quota" in msg or "exceeded" in msg: + return True + if "502" in msg or "503" in msg or "service unavailable" in msg: + return True + if "empty response" in msg: + return True + return False + + +def _create_chained_client(config: AIConfig) -> ChainedAIClient: + """Build a ChainedAIClient from a comma-separated provider chain.""" + from ..models import AI_PROVIDER_DEFAULTS + + provider_names = [p.strip() for p in config.provider_chain.split(",") if p.strip()] + if not provider_names: + raise ValueError("provider_chain is empty") + + chain_configs: List[AIConfig] = [] + for name in provider_names: + try: + provider = AIProvider(name) + except ValueError: + raise ValueError(f"Unsupported AI provider in chain: {name}") + + defaults = AI_PROVIDER_DEFAULTS.get(provider, {}) + cfg = AIConfig( + provider=provider, + model=defaults.get("model", config.model), + api_key_env=defaults.get("api_key_env", config.api_key_env), + base_url=config.base_url, + temperature=config.temperature, + max_tokens=config.max_tokens, + languages=config.languages, + ) + chain_configs.append(cfg) + + return ChainedAIClient(chain_configs) + + +def create_ai_client(config: AIConfig) -> AIClient: + """Factory function to create appropriate AI client. + + Args: + config: AI configuration + + Returns: + AIClient: Initialized AI client + + Raises: + ValueError: If provider is not supported + """ + if config.provider_chain: + return _create_chained_client(config) + return _create_single_client(config) diff --git a/src/ai/enricher.py b/src/ai/enricher.py new file mode 100644 index 0000000..aada753 --- /dev/null +++ b/src/ai/enricher.py @@ -0,0 +1,259 @@ +"""Content enrichment using AI (second-pass analysis). + +For items that pass the score threshold, this module: +1. Searches the web for relevant context (via DuckDuckGo) +2. Feeds search results + item content to AI to generate grounded background knowledge +""" + +import asyncio +import json +import re +import sys +import os +from typing import List, Optional +from tenacity import retry, stop_after_attempt, wait_exponential +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, MofNCompleteColumn +from ddgs import DDGS + +from .client import AIClient +from .prompts import ( + CONCEPT_EXTRACTION_SYSTEM, CONCEPT_EXTRACTION_USER, + CONTENT_ENRICHMENT_SYSTEM, CONTENT_ENRICHMENT_USER, +) +from .utils import parse_json_response +from ..models import ContentItem + + +class ContentEnricher: + """Enriches high-scoring content items with background knowledge.""" + + def __init__(self, ai_client: AIClient): + self.client = ai_client + + def _get_concurrency(self) -> int: + """Return the configured enrichment concurrency, clamped to 1 or above.""" + config = getattr(self.client, "config", None) + concurrency = getattr(config, "enrichment_concurrency", 1) + return max(concurrency, 1) + + async def enrich_batch(self, items: List[ContentItem]) -> None: + """Enrich items in-place with background knowledge. + + Args: + items: Content items to enrich (modified in-place) + """ + concurrency = self._get_concurrency() + semaphore = asyncio.Semaphore(concurrency) + + async def _process(item: ContentItem, progress_task) -> None: + async with semaphore: + try: + await self._enrich_item(item) + except Exception as e: + print(f"Error enriching item {item.id}: {e}, falling back to translation") + await self._translate_item(item) + progress.advance(progress_task) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + transient=True, + ) as progress: + task = progress.add_task("Enriching", total=len(items)) + coros = [ + _process(item, task) for item in items + ] + await asyncio.gather(*coros) + + async def _web_search(self, query: str, max_results: int = 3) -> list: + """Search the web for context via DuckDuckGo. + + Returns: + List of dicts with keys: title, url, body + """ + try: + # Suppress primp "Impersonate ... does not exist" stderr warning + stderr = sys.stderr + sys.stderr = open(os.devnull, "w") + try: + ddgs = DDGS() + results = await asyncio.to_thread(ddgs.text, query, max_results=max_results) + finally: + sys.stderr.close() + sys.stderr = stderr + except Exception: + return [] + + return [ + {"title": r.get("title", ""), "url": r.get("href", ""), "body": r.get("body", "")} + for r in (results or []) + ] + + @staticmethod + def _parse_json_response(response: str) -> Optional[dict]: + """Try multiple strategies to extract a JSON object from an AI response. + + Returns the parsed dict, or None if all strategies fail. + """ + return parse_json_response(response) + + async def _extract_concepts(self, item: ContentItem, content_text: str) -> List[str]: + """Ask AI to identify concepts that need explanation. + + Args: + item: Content item + content_text: Extracted content text + + Returns: + List of search queries for concepts that need explanation + """ + user_prompt = CONCEPT_EXTRACTION_USER.format( + title=item.title, + summary=item.ai_summary or item.title, + tags=", ".join(item.ai_tags) if item.ai_tags else "", + content=content_text[:1000], + ) + + try: + response = await self.client.complete( + system=CONCEPT_EXTRACTION_SYSTEM, + user=user_prompt, + ) + result = self._parse_json_response(response) + if result is None: + return [] + queries = result.get("queries", []) + return queries[:3] + except Exception: + return [] + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(min=2, max=10) + ) + async def _enrich_item(self, item: ContentItem) -> None: + """Enrich a single item with background knowledge. + + Steps: + 1. Ask AI which concepts in the news need explanation + 2. Search the web for those concepts + 3. Ask AI to generate background based on search results + + Args: + item: Content item to enrich (modified in-place via metadata) + """ + # Extract content text and comments separately + content_text = "" + comments_text = "" + if item.content: + if "--- Top Comments ---" in item.content: + main, comments_part = item.content.split("--- Top Comments ---", 1) + content_text = main.strip()[:4000] + comments_text = comments_part.strip()[:2000] + else: + content_text = item.content[:4000] + + # Step 1: AI identifies concepts to explain + queries = await self._extract_concepts(item, content_text) + + # Step 2: Search web for each concept + all_results = [] + web_sections = [] + for query in queries: + results = await self._web_search(query) + all_results.extend(results) + if results: + lines = [f"- [{r['title']}]({r['url']}): {r['body']}" for r in results] + web_sections.append(f"**{query}:**\n" + "\n".join(lines)) + web_context = "\n\n".join(web_sections) if web_sections else "" + + # Index of available URLs for citation validation + available_urls = {r["url"]: r["title"] for r in all_results if r.get("url")} + + # Step 3: AI generates background grounded in search results + user_prompt = CONTENT_ENRICHMENT_USER.format( + title=item.title, + url=str(item.url), + summary=item.ai_summary or item.title, + score=item.ai_score or 0, + reason=item.ai_reason or "", + tags=", ".join(item.ai_tags) if item.ai_tags else "", + content=content_text, + comments_section=f"\n**Community Comments:**\n{comments_text}" if comments_text else "", + web_context=web_context or "No web search results available.", + ) + + response = await self.client.complete( + system=CONTENT_ENRICHMENT_SYSTEM, + user=user_prompt, + ) + + # Parse JSON response with robust fallback + result = self._parse_json_response(response) + if result is None: + # Gracefully degrade: fall back to a lightweight translation + # instead of dropping the item untranslated. + print(f"Warning: could not parse enrichment response for {item.id}, falling back to translation") + await self._translate_item(item) + return + + # Combine structured sub-fields into per-language detailed_summary + for lang in ("en", "zh"): + if result.get(f"title_{lang}"): + val = result[f"title_{lang}"] + item.metadata[f"title_{lang}"] = val.get("text") or str(val) if isinstance(val, dict) else str(val) + + parts = [] + for field in ("whats_new", "why_it_matters", "key_details"): + text = result.get(f"{field}_{lang}", "").strip() + if text: + parts.append(text) + if parts: + item.metadata[f"detailed_summary_{lang}"] = " ".join(parts) + + if result.get(f"background_{lang}"): + val = result[f"background_{lang}"] + item.metadata[f"background_{lang}"] = val.get("text") or str(val) if isinstance(val, dict) else str(val) + + if result.get(f"community_discussion_{lang}"): + val = result[f"community_discussion_{lang}"] + item.metadata[f"community_discussion_{lang}"] = val.get("text") or str(val) if isinstance(val, dict) else str(val) + + # Store citation sources — only URLs that actually came from our search results + if result.get("sources") and available_urls: + valid = [ + {"url": u, "title": available_urls[u]} + for u in result["sources"] + if u in available_urls + ] + if valid: + item.metadata["sources"] = valid + + # Backward-compatible fallback fields (English as default) + item.metadata["detailed_summary"] = item.metadata.get("detailed_summary_en", "") + item.metadata["background"] = item.metadata.get("background_en", "") + item.metadata["community_discussion"] = item.metadata.get("community_discussion_en", "") + + async def _translate_item(self, item: ContentItem) -> None: + """Lightweight translation fallback: when full enrichment fails, at least + translate the title and summary to Chinese so the item is not dropped.""" + try: + response = await self.client.complete( + system="You are a translator. Translate to Simplified Chinese. Return only valid JSON, no other text.", + user=( + f'Title: {item.title}\n' + f'Summary: {item.ai_summary or item.title}\n\n' + 'Return JSON:\n' + '{"title_zh": "<中文标题>", "summary_zh": "<用中文写1-2句摘要>"}' + ), + ) + result = self._parse_json_response(response) + if result: + if result.get("title_zh"): + item.metadata["title_zh"] = result["title_zh"] + if result.get("summary_zh"): + item.metadata["detailed_summary_zh"] = result["summary_zh"] + except Exception: + pass diff --git a/src/ai/markdown_utils.py b/src/ai/markdown_utils.py new file mode 100644 index 0000000..88020c1 --- /dev/null +++ b/src/ai/markdown_utils.py @@ -0,0 +1,103 @@ +"""Utilities for normalizing app-generated Markdown.""" + +import html +import re +from urllib.parse import urlsplit + + +_DETAILS_RE = re.compile( + r"
\s*(.*?)\s*(.*?)\s*
", + re.IGNORECASE | re.DOTALL, +) +_ANCHOR_LINK_RE = re.compile( + r"^\s*]*href=[\"']([^\"']+)[\"'][^>]*>(.*?)\s*$", + re.IGNORECASE | re.DOTALL, +) +_LI_RE = re.compile(r"
  • \s*(.*?)\s*
  • ", re.IGNORECASE | re.DOTALL) +_ANCHOR_ID_RE = re.compile( + r"]*id=[\"'][^\"']+[\"'][^>]*>\s*", re.IGNORECASE +) +_HTML_TAG_RE = re.compile(r"<[^>]+>") +_MARKDOWN_SPECIAL_RE = re.compile(r"([\\`*_{}\[\]<>()#+!|])") +_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]") +_UNSAFE_MARKDOWN_URL_CHAR_RE = re.compile(r"[\s<>\[\]\\]") +_SAFE_URL_SCHEMES = {"http", "https", "mailto"} + + +def _strip_html_tags(value: str) -> str: + """Remove simple HTML tags and decode HTML entities.""" + return html.unescape(_HTML_TAG_RE.sub("", value)).strip() + + +def _escape_markdown_text(value: str) -> str: + """Escape user-controlled text before embedding it in generated Markdown.""" + clean_value = re.sub(r"\s+", " ", value).strip() + return _MARKDOWN_SPECIAL_RE.sub(r"\\\1", clean_value) + + +def _is_safe_markdown_link_url(value: str) -> bool: + """Return True when a URL can be emitted as an active Markdown link.""" + if _CONTROL_CHAR_RE.search(value): + return False + if _UNSAFE_MARKDOWN_URL_CHAR_RE.search(value): + return False + if value.count("(") != value.count(")"): + return False + + try: + parsed = urlsplit(value) + except ValueError: + return False + + scheme = parsed.scheme.lower() + if scheme not in _SAFE_URL_SCHEMES: + return False + + if scheme in {"http", "https"}: + return bool(parsed.netloc) + + return bool(parsed.path) + + +def _convert_details_to_markdown(value: str) -> str: + """Convert HTML details blocks into plain Markdown sections.""" + + def _replace(match: re.Match) -> str: + title = _escape_markdown_text(_strip_html_tags(match.group(1)) or "References") + body = match.group(2) + items: list[str] = [] + + for item in _LI_RE.findall(body): + link_match = _ANCHOR_LINK_RE.match(item) + if link_match: + href, label = link_match.groups() + clean_label = _strip_html_tags(label) + escaped_label = _escape_markdown_text(clean_label) + clean_href = html.unescape(href).strip() + if escaped_label and clean_href and _is_safe_markdown_link_url(clean_href): + items.append(f"- [{escaped_label}]({clean_href})") + elif escaped_label: + items.append(f"- {escaped_label}") + continue + + clean_item = _escape_markdown_text(_strip_html_tags(item)) + if clean_item: + items.append(f"- {clean_item}") + + if not items: + fallback = _escape_markdown_text(_strip_html_tags(body)) + return f"**{title}**\n\n{fallback}" if fallback else f"**{title}**" + + return f"**{title}**\n\n" + "\n".join(items) + + return _DETAILS_RE.sub(_replace, value) + + +def clean_app_summary_markdown(value: str) -> str: + """Flatten app-generated HTML snippets embedded in summary Markdown. + + Unknown raw HTML is intentionally left in place so callers can choose their + own safety boundary. Email, for example, escapes after this cleanup step. + """ + value = _ANCHOR_ID_RE.sub("", value) + return _convert_details_to_markdown(value) diff --git a/src/ai/prompts.py b/src/ai/prompts.py new file mode 100644 index 0000000..f67d4ac --- /dev/null +++ b/src/ai/prompts.py @@ -0,0 +1,172 @@ +"""AI prompts for content analysis and summarization.""" + +TOPIC_DEDUP_SYSTEM = """You are a news deduplication assistant. Identify groups of news items that cover the exact same real-world event, release, or announcement. + +Rules: +- Group items ONLY if they report on the identical event (same product release, same incident, same announcement) +- Items about the same product but different events are NOT duplicates ("Gemma 4 released" vs "Gemma 4 jailbroken") +- Err on the side of keeping items separate when unsure""" + +TOPIC_DEDUP_USER = """The following news items have already been sorted by importance score (descending). Identify which items are duplicates of each other. + +{items} + +Return a JSON object listing only the groups that contain duplicates (2+ items). Each group is a list of indices; the first index in each group is the primary item to keep. + +Respond with valid JSON only: +{{ + "duplicates": [[, , ...], ...] +}} + +If there are no duplicates at all, return: {{"duplicates": []}}""" + +CONTENT_ANALYSIS_SYSTEM = """You are an expert content curator helping filter important technical and academic information. + +Score content on a 0-10 scale based on importance and relevance: + +**9-10: Groundbreaking** - Major breakthroughs, paradigm shifts, or highly significant announcements +- New major version releases of widely-used technologies +- Significant research breakthroughs +- Important industry-changing announcements + +**7-8: High Value** - Important developments worth immediate attention +- Interesting technical deep-dives +- Novel approaches to known problems +- Insightful analysis or commentary +- Valuable tools or libraries + +**5-6: Interesting** - Worth knowing but not urgent +- Incremental improvements +- Useful tutorials +- Moderate community interest + +**3-4: Low Priority** - Generic or routine content +- Minor updates +- Common knowledge +- Overly promotional content + +**0-2: Noise** - Not relevant or low quality +- Spam or purely promotional +- Off-topic content +- Trivial updates + +Consider: +- Technical depth and novelty +- Potential impact on the field +- Quality of writing/presentation +- Relevance to software engineering, AI/ML, and systems research +- Community discussion quality: insightful comments, diverse viewpoints, and debates increase value +- Engagement signals: high upvotes/favorites with substantive discussion indicate community-validated importance +""" + +CONTENT_ANALYSIS_USER = """Analyze the following content and provide a JSON response with: +- score (0-10): Importance score +- reason: Brief explanation for the score (mention discussion quality if comments are provided) +- summary: One-sentence summary of the content +- tags: Relevant topic tags (3-5 tags) + +Content: +Title: {title} +Source: {source} +Author: {author} +URL: {url} +{content_section} +{discussion_section} + +Respond with valid JSON only: +{{ + "score": , + "reason": "", + "summary": "", + "tags": ["", "", ...] +}}""" + +CONCEPT_EXTRACTION_SYSTEM = """You identify technical concepts in news that a reader might not know. +Given a news item, return 1-3 search queries for concepts that need explanation. +Focus on: specific technologies, protocols, algorithms, tools, or projects that are not widely known. +Do NOT return queries for well-known things (e.g. "Python", "Linux", "Google"). +If the news is self-explanatory, return an empty list.""" + +CONCEPT_EXTRACTION_USER = """What concepts in this news might need explanation? + +Title: {title} +Summary: {summary} +Tags: {tags} +Content: {content} + +Respond with valid JSON only: +{{ + "queries": ["", ""] +}}""" + +CONTENT_ENRICHMENT_SYSTEM = """You are a knowledgeable technical writer who helps readers understand important news in context. + +Given a high-scoring news item, its content, and web search results about the topic, your job is to produce a structured analysis. + +Provide EACH text field in BOTH English and Chinese. Use the following key naming convention: +- title_en / title_zh +- whats_new_en / whats_new_zh +- why_it_matters_en / why_it_matters_zh +- key_details_en / key_details_zh +- background_en / background_zh +- community_discussion_en / community_discussion_zh + +Field definitions: +0. **title** (one short phrase, ≤15 words): A clear, accurate headline for the news item. + +1. **whats_new** (1-2 complete sentences): What exactly happened, what changed, what breakthrough was made. Be specific — mention names, versions, numbers, dates when available. + +2. **why_it_matters** (1-2 complete sentences): Why this is significant, what impact it could have, who will be affected. Connect to the broader ecosystem or industry trends. + +3. **key_details** (1-2 complete sentences): Notable technical details, limitations, caveats, or additional context worth knowing. Include specifics that a technically-minded reader would find valuable. + +4. **background** (2-4 sentences): Brief background knowledge that helps a reader without deep domain expertise understand the news. Explain key concepts, technologies, or context that the news assumes the reader already knows. + +5. **community_discussion** (1-3 sentences): If community comments are provided, summarize the overall sentiment and key viewpoints from the discussion — agreements, disagreements, concerns, additional insights, or notable counterarguments. If no comments are provided, return an empty string. + +**CRITICAL — Language rules (MUST follow):** +- All *_en fields MUST be written in English. +- All *_zh fields MUST be written in Simplified Chinese (简体中文). 绝对不能用英文写 _zh 字段的内容。Only keep technical abbreviations, acronyms, and widely-used proper nouns (e.g. "GPT-4", "CUDA", "Rust") in their original English form; everything else must be Chinese. + +Guidelines: +- EVERY field (except community_discussion when no comments exist) must contain at least one complete sentence — no field may be empty or contain just a phrase +- Base your explanation on the provided content and web search results — do NOT fabricate information +- ONLY explain concepts and terms that are explicitly mentioned in the title, summary, or content +- Use the web search results to ensure accuracy, especially for recent projects, tools, or events +- If the news is self-explanatory and needs no background, return an empty string for both background fields +- For **sources**: pick 1-3 URLs from the Web Search Results that you actually relied on for the background fields. Only use URLs that appear verbatim in the search results above — do not invent or modify URLs. +""" + +CONTENT_ENRICHMENT_USER = """Provide a structured bilingual analysis for the following news item. + +**News Item:** +- Title: {title} +- URL: {url} +- One-line summary: {summary} +- Score: {score}/10 +- Reason: {reason} +- Tags: {tags} + +**Content:** +{content} +{comments_section} + +**Web Search Results (for grounding):** +{web_context} + +Respond with valid JSON only. Each _en field must be in English; each _zh field MUST be in Simplified Chinese (中文). Every field MUST be at least one complete sentence (except community_discussion fields when no comments exist): +{{ + "title_en": "", + "title_zh": "<用中文写一个简短标题,不超过15个词>", + "whats_new_en": "<1-2 sentences in English>", + "whats_new_zh": "<用中文写1-2句话>", + "why_it_matters_en": "<1-2 sentences in English>", + "why_it_matters_zh": "<用中文写1-2句话>", + "key_details_en": "<1-2 sentences in English>", + "key_details_zh": "<用中文写1-2句话>", + "background_en": "<2-4 sentences in English, or empty string>", + "background_zh": "<用中文写2-4句话,或空字符串>", + "community_discussion_en": "<1-3 sentences in English, or empty string>", + "community_discussion_zh": "<用中文写1-3句话,或空字符串>", + "sources": ["", "..."] +}}""" diff --git a/src/ai/summarizer.py b/src/ai/summarizer.py new file mode 100644 index 0000000..08b9dca --- /dev/null +++ b/src/ai/summarizer.py @@ -0,0 +1,257 @@ +"""Daily summary generation — pure programmatic rendering.""" + +import re +from typing import List, Dict + +from ..models import ContentItem + + +_CJK = r"[\u4e00-\u9fff\u3400-\u4dbf]" +_ASCII = r"[A-Za-z0-9]" + + +def _pangu(text: str) -> str: + """Insert a space between CJK and ASCII letters/digits (Pangu spacing).""" + text = re.sub(rf"({_CJK})({_ASCII})", r"\1 \2", text) + text = re.sub(rf"({_ASCII})({_CJK})", r"\1 \2", text) + return text + + +LABELS = { + "en": { + "header": "Horizon Daily", + "source": "Source", + "background": "Background", + "discussion": "Discussion", + "references": "References", + "tags": "Tags", + "selected_items": "From {total} items, {selected} important content pieces were selected", + "empty_analyzed": "Analyzed {total} items, but none met the importance threshold.", + "empty_body": ( + "No significant developments today. This might indicate:\n" + "- A quiet day in your tracked sources\n" + "- The AI score threshold is too high\n" + "- Your information sources need expansion\n\n" + "Consider:\n" + "1. Lowering the `ai_score_threshold` in config.json\n" + "2. Adding more diverse information sources\n" + "3. Checking if the AI model is working correctly\n" + ), + }, + "zh": { + "header": "Horizon 每日速递", + "source": "来源", + "background": "背景", + "discussion": "社区讨论", + "references": "参考链接", + "tags": "标签", + "selected_items": "从 {total} 条内容中筛选出 {selected} 条重要资讯。", + "empty_analyzed": "已分析 {total} 条内容,但没有达到重要性阈值的条目。", + "empty_body": ( + "今日暂无重要动态,可能原因:\n" + "- 今天关注的信息源较平静\n" + "- AI 评分阈值设置过高\n" + "- 信息源种类有待扩充\n\n" + "建议:\n" + "1. 在 config.json 中降低 `ai_score_threshold`\n" + "2. 添加更多多样化的信息源\n" + "3. 检查 AI 模型是否正常工作\n" + ), + }, +} + + +class DailySummarizer: + """Generates daily Markdown summaries from pre-analyzed content items.""" + + def __init__(self): + pass + + async def generate_summary( + self, + items: List[ContentItem], + date: str, + total_fetched: int, + language: str = "en", + ) -> str: + """Generate daily summary in Markdown format. + + Items are rendered in score-descending order (already sorted by orchestrator). + + Args: + items: High-scoring content items (already enriched) + date: Date string (YYYY-MM-DD) + total_fetched: Total number of items fetched before filtering + language: Output language, either "en" or "zh" + + Returns: + str: Markdown formatted summary + """ + labels = LABELS.get(language, LABELS["en"]) + + if not items: + return self._generate_empty_summary(date, total_fetched, labels) + + header = ( + f"# {labels['header']} - {date}\n\n" + f"> {labels['selected_items'].format(total=total_fetched, selected=len(items))}\n\n" + "---\n\n" + ) + + # TOC + toc_entries = [] + for i, item in enumerate(items): + _t = item.metadata.get(f"title_{language}") or item.title + t = str(_t).replace("[", "(").replace("]", ")") + if language == "zh": + t = _pangu(t) + score = item.ai_score or "?" + toc_entries.append(f"{i + 1}. [{t}](#item-{i + 1}) \u2b50\ufe0f {score}/10") + toc = "\n".join(toc_entries) + "\n\n---\n\n" + + parts = [self._format_item(item, labels, language, i + 1) for i, item in enumerate(items)] + + return header + toc + "".join(parts) + + def generate_webhook_overview( + self, + items: List[ContentItem], + date: str, + total_fetched: int, + language: str = "en", + ) -> str: + """Generate a compact overview for multi-message webhook delivery.""" + labels = LABELS.get(language, LABELS["en"]) + if not items: + return self._generate_empty_summary(date, total_fetched, labels) + + if language == "zh": + header = ( + f"# {labels['header']} - {date}\n\n" + f"> 从 {total_fetched} 条内容中筛选出 {len(items)} 条重要资讯。\n\n" + "下面会按新闻逐条发送详情,你可以只看感兴趣的标题。\n\n" + ) + else: + header = ( + f"# {labels['header']} - {date}\n\n" + f"> Selected {len(items)} important items from {total_fetched} fetched items.\n\n" + "Details will be sent item by item so you can read only the topics you care about.\n\n" + ) + + entries = [] + for i, item in enumerate(items, start=1): + title = str(item.metadata.get(f"title_{language}") or item.title).replace("[", "(").replace("]", ")") + if language == "zh": + title = _pangu(title) + score = item.ai_score or "?" + entries.append(f"{i}. [{title}]({item.url}) \u2b50\ufe0f {score}/10") + + return header + "\n".join(entries) + + def generate_webhook_item( + self, + item: ContentItem, + language: str, + index: int, + total: int, + ) -> str: + """Generate one item message for multi-message webhook delivery.""" + labels = LABELS.get(language, LABELS["en"]) + prefix = f"第 {index}/{total} 条\n\n" if language == "zh" else f"Item {index}/{total}\n\n" + return prefix + self._format_item(item, labels, language, index).rstrip("-\n ") + + def _format_item(self, item: ContentItem, labels: dict, language: str, index: int) -> str: + """Format a single ContentItem into Markdown.""" + _title = item.metadata.get(f"title_{language}") or item.title + title = str(_title).replace("[", "(").replace("]", ")") + url = str(item.url) + score = item.ai_score or "?" + meta = item.metadata + + summary = ( + meta.get(f"detailed_summary_{language}") + or meta.get("detailed_summary") + or item.ai_summary + or "" + ) + background = meta.get(f"background_{language}") or meta.get("background") or "" + discussion = ( + meta.get(f"community_discussion_{language}") + or meta.get("community_discussion") + or "" + ) + + if language == "zh": + title = _pangu(title) + summary = _pangu(summary) + background = _pangu(background) + discussion = _pangu(discussion) + + # Source line with parts joined by " · ", link appended at end + source_type = item.source_type.value + source_parts = [source_type] + if meta.get("subreddit"): + source_parts.append(f"r/{meta['subreddit']}") + if meta.get("feed_name"): + source_parts.append(meta["feed_name"]) + else: + source_parts.append(item.author or "unknown") + if item.published_at: + if language == "zh": + source_parts.append( + f"{item.published_at.month}月{item.published_at.day}日 " + f"{item.published_at:%H:%M}" + ) + else: + day = item.published_at.strftime("%d").lstrip("0") + source_parts.append(item.published_at.strftime(f"%b {day}, %H:%M")) + source_line = " \u00b7 ".join(source_parts) # · + + discussion_url = meta.get("discussion_url") + if discussion_url: + discussion_url = str(discussion_url) + if discussion_url != url: + source_line += f' · [{labels["discussion"]}]({discussion_url})' + + lines = [ + f'', + f"## [{title}]({url}) \u2b50\ufe0f {score}/10", # ⭐️ + "", + summary, + "", + source_line, + ] + + if background: + lines.append("") + lines.append(f"**{labels['background']}**: {background}") + + sources = meta.get("sources") or [] + if sources: + items_html = "".join(f'
  • {s["title"]}
  • \n' for s in sources) + lines += [ + "", + f'
    {labels["references"]}\n
      \n{items_html}\n
    \n
    ', + ] + + if discussion: + lines.append("") + lines.append(f"**{labels['discussion']}**: {discussion}") + + if item.ai_tags: + tags_str = ", ".join([f"`#{t}`" for t in item.ai_tags]) + lines.append("") + lines.append(f"**{labels['tags']}**: {tags_str}") + + lines.append("") + lines.append("---") + + return "\n".join(lines) + "\n\n" + + def _generate_empty_summary(self, date: str, total_fetched: int, labels: dict) -> str: + """Generate summary when no high-scoring items were found.""" + return ( + f"# {labels['header']} - {date}\n\n" + f"> {labels['empty_analyzed'].format(total=total_fetched)}\n\n" + + labels["empty_body"] + ) diff --git a/src/ai/tokens.py b/src/ai/tokens.py new file mode 100644 index 0000000..0fc3752 --- /dev/null +++ b/src/ai/tokens.py @@ -0,0 +1,66 @@ +"""Lightweight token usage tracker shared across AI clients. + +This module keeps a simple in-memory counter of tokens used during a single +Horizon run, so the orchestrator can print a summary at the end. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict + + +@dataclass +class ProviderUsage: + input_tokens: int = 0 + output_tokens: int = 0 + + @property + def total(self) -> int: + return self.input_tokens + self.output_tokens + + +@dataclass +class TokenUsageSnapshot: + total_input_tokens: int + total_output_tokens: int + per_provider: Dict[str, ProviderUsage] = field(default_factory=dict) + + @property + def total_tokens(self) -> int: + return self.total_input_tokens + self.total_output_tokens + + +_provider_usage: Dict[str, ProviderUsage] = {} + + +def record_usage(provider: str, input_tokens: int = 0, output_tokens: int = 0) -> None: + """Accumulate token usage for a given provider. + + Args: + provider: Provider identifier, e.g. "openai", "anthropic". + input_tokens: Prompt / input tokens used. + output_tokens: Completion / output tokens used. + """ + if input_tokens <= 0 and output_tokens <= 0: + return + + usage = _provider_usage.setdefault(provider, ProviderUsage()) + usage.input_tokens += max(0, input_tokens) + usage.output_tokens += max(0, output_tokens) + + +def get_usage_snapshot() -> TokenUsageSnapshot: + """Return a snapshot of accumulated token usage.""" + total_in = sum(u.input_tokens for u in _provider_usage.values()) + total_out = sum(u.output_tokens for u in _provider_usage.values()) + return TokenUsageSnapshot( + total_input_tokens=total_in, + total_output_tokens=total_out, + per_provider=dict(_provider_usage), + ) + + +def reset_usage() -> None: + """Reset all accumulated usage (useful for tests).""" + _provider_usage.clear() diff --git a/src/ai/utils.py b/src/ai/utils.py new file mode 100644 index 0000000..df1eee4 --- /dev/null +++ b/src/ai/utils.py @@ -0,0 +1,60 @@ +"""Shared AI utility functions.""" + +import json +import re +from typing import Optional + + +def parse_json_response(response: str) -> Optional[dict]: + """Try multiple strategies to extract a JSON object from an AI response. + + Returns the parsed dict, or None if all strategies fail. + """ + text = response.strip() + + # Strategy 1: direct parse + try: + return json.loads(text) + except (json.JSONDecodeError, ValueError): + pass + + # Strategy 2: extract from ```json ... ``` code block + if "```json" in text: + try: + json_str = text.split("```json")[1].split("```")[0].strip() + return json.loads(json_str) + except (json.JSONDecodeError, ValueError, IndexError): + pass + + # Strategy 3: extract from ``` ... ``` code block + if "```" in text: + try: + json_str = text.split("```")[1].split("```")[0].strip() + return json.loads(json_str) + except (json.JSONDecodeError, ValueError, IndexError): + pass + + # Strategy 4: find the first { ... } block using brace matching + start = text.find("{") + if start != -1: + depth = 0 + for i in range(start, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(text[start : i + 1]) + except (json.JSONDecodeError, ValueError): + break + + # Strategy 5: regex extraction as last resort + match = re.search(r"\{[\s\S]*\}", text) + if match: + try: + return json.loads(match.group()) + except (json.JSONDecodeError, ValueError): + pass + + return None diff --git a/src/extractors/__init__.py b/src/extractors/__init__.py new file mode 100644 index 0000000..291c901 --- /dev/null +++ b/src/extractors/__init__.py @@ -0,0 +1,5 @@ +from .base import BaseExtractor +from .registry import ExtractorRegistry +from .trafilatura import TrafilaturaExtractor + +__all__ = ["BaseExtractor", "ExtractorRegistry", "TrafilaturaExtractor"] diff --git a/src/extractors/base.py b/src/extractors/base.py new file mode 100644 index 0000000..16a0ea9 --- /dev/null +++ b/src/extractors/base.py @@ -0,0 +1,12 @@ +"""Base extractor interface.""" + +from abc import ABC, abstractmethod +from typing import Optional + +import httpx + + +class BaseExtractor(ABC): + @abstractmethod + async def extract(self, url: str, client: httpx.AsyncClient) -> Optional[str]: + """Fetch and extract article text from url. Returns None on failure.""" diff --git a/src/extractors/registry.py b/src/extractors/registry.py new file mode 100644 index 0000000..d76a19b --- /dev/null +++ b/src/extractors/registry.py @@ -0,0 +1,32 @@ +"""Extractor registry.""" + +from typing import Dict, Optional + +from .base import BaseExtractor +from .trafilatura import TrafilaturaExtractor +from ..models import ExtractorConfig, ExtractorType, TrafilaturaExtractorConfig + +_DEFAULTS: Dict[str, ExtractorConfig] = { + ExtractorType.TRAFILATURA: TrafilaturaExtractorConfig(), +} + + +def _build(cfg: ExtractorConfig) -> BaseExtractor: + match cfg: + case TrafilaturaExtractorConfig(): + return TrafilaturaExtractor(cfg) + case _: + raise NotImplementedError(f"Extractor type '{cfg.type}' is not yet implemented") + + +class ExtractorRegistry: + def __init__(self, config: Dict[str, ExtractorConfig]): + self._extractors: Dict[str, BaseExtractor] = { + name: _build(cfg) for name, cfg in _DEFAULTS.items() + } + self._extractors.update({ + name: _build(cfg) for name, cfg in config.items() + }) + + def get(self, name: str) -> Optional[BaseExtractor]: + return self._extractors.get(name) diff --git a/src/extractors/trafilatura.py b/src/extractors/trafilatura.py new file mode 100644 index 0000000..685a156 --- /dev/null +++ b/src/extractors/trafilatura.py @@ -0,0 +1,40 @@ +"""Trafilatura-based article extractor.""" + +import logging +from typing import Optional + +import httpx + +from .base import BaseExtractor +from ..models import TrafilaturaExtractorConfig + +logger = logging.getLogger(__name__) + + +class TrafilaturaExtractor(BaseExtractor): + def __init__(self, config: TrafilaturaExtractorConfig): + self._config = config + + async def extract(self, url: str, client: httpx.AsyncClient) -> Optional[str]: + try: + import trafilatura + except ImportError: + logger.warning("trafilatura is not installed; install with: uv pip install trafilatura>=2.1.0") + return None + + try: + response = await client.get(url, follow_redirects=True) + response.raise_for_status() + except httpx.HTTPError as e: + logger.warning("Failed to fetch article %s: %s", url, e) + return None + + try: + return trafilatura.extract( + response.text, + favor_precision=self._config.favor_precision, + favor_recall=self._config.favor_recall, + ) or None + except Exception as e: + logger.warning("trafilatura extraction failed for %s: %s", url, e) + return None diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..abdff10 --- /dev/null +++ b/src/main.py @@ -0,0 +1,141 @@ +"""CLI entry point for Horizon.""" + +import argparse +import asyncio +import sys +from pathlib import Path + +from dotenv import load_dotenv +from rich.console import Console + +from .storage.manager import ConfigError, StorageManager +from .orchestrator import HorizonOrchestrator + + +console = Console() + + +def print_banner(): + """Print the application banner.""" + banner = r""" +[bold blue] + _ _ _ + | | | | (_) + | |__| | ___ _ __ _ ___ ___ _ __ + | __ |/ _ \| '__| |_ / / _ \| '_ \ + | | | | (_) | | | |/ / | (_) | | | | + |_| |_|\___/|_| |_/___| \___/|_| |_| +[/bold blue] +[cyan] AI-Driven Information Aggregation System[/cyan] + """ + console.print(banner) + + +def main(): + """Main CLI entry point.""" + print_banner() + + parser = argparse.ArgumentParser(description="Horizon - AI-Driven Information Aggregation System") + parser.add_argument("--hours", type=int, help="Force fetch from last N hours") + args = parser.parse_args() + + try: + # Load environment variables from .env file + load_dotenv() + + # Ensure we're in the project directory or use data/ in current dir + data_dir = Path("data") + + # Initialize storage manager + storage = StorageManager(data_dir=str(data_dir)) + + # Load configuration + try: + config = storage.load_config() + except FileNotFoundError: + console.print("[bold red]❌ Configuration file not found![/bold red]\n") + data_dir_path = data_dir if isinstance(data_dir, Path) else Path(data_dir) + example_path = data_dir_path / "config.example.json" + if example_path.exists(): + console.print( + f"Copy the example config and edit it:\n" + f" [cyan]cp {example_path} {data_dir_path / 'config.json'}[/cyan]\n" + ) + console.print( + "Or run [bold cyan]uv run horizon-wizard[/bold cyan] to launch the interactive setup wizard.\n" + ) + sys.exit(1) + except ConfigError as e: + console.print(f"[bold red]❌ Error loading configuration: {e}[/bold red]") + sys.exit(1) + except Exception as e: + console.print(f"[bold red]❌ Error loading configuration: {e}[/bold red]") + sys.exit(1) + + # Create and run orchestrator + orchestrator = HorizonOrchestrator(config, storage) + asyncio.run(orchestrator.run(force_hours=args.hours)) + + except KeyboardInterrupt: + console.print("\n[yellow]⚠️ Interrupted by user[/yellow]") + sys.exit(0) + except Exception as e: + console.print(f"\n[bold red]❌ Fatal error: {e}[/bold red]") + import traceback + traceback.print_exc() + sys.exit(1) + + +def print_config_template(): + """Print configuration template.""" + template = """ +{ + "version": "1.0", + "ai": { + "provider": "anthropic", + "model": "claude-sonnet-4.5-20250929", + "api_key_env": "ANTHROPIC_API_KEY", + "temperature": 0.3, + "max_tokens": 4096 + }, + "sources": { + "github": [ + { + "type": "user_events", + "username": "torvalds", + "enabled": true + } + ], + "hackernews": { + "enabled": true, + "fetch_top_stories": 30, + "min_score": 100 + }, + "rss": [ + { + "name": "Example Blog", + "url": "https://example.com/feed.xml", + "enabled": true, + "category": "software-engineering" + } + ] + }, + "filtering": { + "ai_score_threshold": 7.0, + "time_window_hours": 24, + "max_items": null, + "category_groups": {}, + "default_group": "other", + "default_group_limit": null + } +} + +Also create a .env file with: +ANTHROPIC_API_KEY=your_api_key_here +GITHUB_TOKEN=your_github_token_here (optional but recommended) +""" + console.print(template) + + +if __name__ == "__main__": + main() diff --git a/src/mcp/README.md b/src/mcp/README.md new file mode 100644 index 0000000..4b079c9 --- /dev/null +++ b/src/mcp/README.md @@ -0,0 +1,62 @@ +# Horizon MCP + +Horizon includes a built-in MCP server that exposes the native Horizon pipeline as staged tools and read-only resources. + +The MCP layer does not reimplement Horizon business logic. It reuses the existing fetch, score, filter, enrich, and summarize modules from the main codebase. + +## Tools + +| Tool | Description | +| --- | --- | +| `hz_validate_config` | Validate Horizon config and required environment variables | +| `hz_fetch_items` | Fetch and deduplicate content into the `raw` stage | +| `hz_score_items` | Score items from a stage into `scored` | +| `hz_filter_items` | Filter scored items into `filtered` | +| `hz_enrich_items` | Enrich filtered items into `enriched` | +| `hz_generate_summary` | Generate markdown from a stage | +| `hz_run_pipeline` | Run fetch -> score -> filter -> enrich -> summarize | +| `hz_list_runs` | List recent run artifacts | +| `hz_get_run_meta` | Read metadata for a run | +| `hz_get_run_stage` | Read items from a run stage | +| `hz_get_run_summary` | Read a generated summary | +| `hz_get_metrics` | Read in-memory server metrics | + +## Resources + +- `horizon://server/info` +- `horizon://metrics` +- `horizon://runs` +- `horizon://runs/{run_id}/meta` +- `horizon://runs/{run_id}/items/{stage}` +- `horizon://runs/{run_id}/summary/{language}` +- `horizon://config/effective` + +## Install and Start + +```bash +uv sync +uv run horizon-mcp +``` + +The server runs over stdio and is intended to be launched by an MCP client. + +## Run Artifacts + +Each run writes artifacts under `data/mcp-runs//`: + +- `meta.json` +- `raw_items.json` +- `scored_items.json` +- `filtered_items.json` +- `enriched_items.json` +- `summary-.md` + +## Design Principles + +1. Keep Horizon as the single source of business logic. +2. Preserve staged re-entry so a run can continue from intermediate artifacts. +3. Default to no extra side effects unless explicitly requested. + +## Client Setup + +See [integration.md](integration.md). diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py new file mode 100644 index 0000000..cac3414 --- /dev/null +++ b/src/mcp/__init__.py @@ -0,0 +1,5 @@ +"""Horizon MCP package.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/mcp/errors.py b/src/mcp/errors.py new file mode 100644 index 0000000..e81019e --- /dev/null +++ b/src/mcp/errors.py @@ -0,0 +1,18 @@ +"""Error definitions for Horizon MCP service.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class HorizonMcpError(Exception): + """Structured exception with stable error code.""" + + code: str + message: str + details: Any = None + + def __str__(self) -> str: # pragma: no cover - trivial + return f"{self.code}: {self.message}" diff --git a/src/mcp/horizon_adapter.py b/src/mcp/horizon_adapter.py new file mode 100644 index 0000000..c0dfc16 --- /dev/null +++ b/src/mcp/horizon_adapter.py @@ -0,0 +1,342 @@ +"""Adapter layer that reuses Horizon's native Python modules.""" + +from __future__ import annotations + +import importlib +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv + +from .errors import HorizonMcpError + + +VALID_SOURCES = { + "github", + "hackernews", + "rss", + "reddit", + "telegram", + "twitter", + "openbb", +} +ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") + + +@dataclass +class HorizonRuntime: + """Loaded runtime references from a Horizon codebase.""" + + horizon_path: Path + ContentItem: Any + Config: Any + StorageManager: Any + HorizonOrchestrator: Any + create_ai_client: Any + ContentAnalyzer: Any + ContentEnricher: Any + DailySummarizer: Any + expand_env_vars: Any + + +def resolve_horizon_path(explicit: str | None = None) -> Path: + """Resolve Horizon repository path by explicit arg/env/common locations.""" + + candidates: list[Path] = [] + if explicit: + candidates.append(Path(explicit).expanduser()) + + env_path = os.getenv("HORIZON_PATH") + if env_path: + candidates.append(Path(env_path).expanduser()) + + repo_root = Path(__file__).resolve().parents[2] + cwd = Path.cwd() + candidates.extend( + [ + repo_root, + cwd, + cwd / "Horizon", + cwd.parent / "Horizon", + ] + ) + + seen: set[Path] = set() + for candidate in candidates: + path = candidate.resolve() + if path in seen: + continue + seen.add(path) + if _is_horizon_repo(path): + return path + + checked = ", ".join(str(p.resolve()) for p in candidates) + raise HorizonMcpError( + code="HZ_HORIZON_NOT_FOUND", + message="Horizon repository was not found. Pass horizon_path or set HORIZON_PATH.", + details={"checked": checked}, + ) + + +def resolve_config_path(horizon_path: Path, config_path: str | None = None) -> Path: + """Resolve config path, defaulting to /data/config.json.""" + + if not config_path: + path = (horizon_path / "data/config.json").resolve() + else: + raw = Path(config_path).expanduser() + if raw.is_absolute(): + path = raw.resolve() + else: + candidate = (horizon_path / raw).resolve() + path = candidate if candidate.exists() else (Path.cwd() / raw).resolve() + + if not path.exists(): + raise HorizonMcpError( + code="HZ_CONFIG_NOT_FOUND", + message="Config file does not exist.", + details={"config_path": str(path)}, + ) + + return path + + +def load_runtime(horizon_path: Path) -> HorizonRuntime: + """Load Horizon modules dynamically from local repository path.""" + + if not _is_horizon_repo(horizon_path): + raise HorizonMcpError( + code="HZ_INVALID_HORIZON_PATH", + message="horizon_path is not a valid Horizon repository.", + details={"horizon_path": str(horizon_path)}, + ) + + load_dotenv(horizon_path / ".env", override=False) + _load_mcp_secrets(horizon_path, override=False) + + horizon_path_str = str(horizon_path) + if horizon_path_str not in sys.path: + sys.path.insert(0, horizon_path_str) + + try: + models = importlib.import_module("src.models") + storage = importlib.import_module("src.storage.manager") + orchestrator = importlib.import_module("src.orchestrator") + ai_client = importlib.import_module("src.ai.client") + analyzer = importlib.import_module("src.ai.analyzer") + enricher = importlib.import_module("src.ai.enricher") + summarizer = importlib.import_module("src.ai.summarizer") + except Exception as exc: # pragma: no cover - import failure edge case + raise HorizonMcpError( + code="HZ_IMPORT_FAILED", + message="Failed to load Horizon modules.", + details={"error": str(exc)}, + ) from exc + + return HorizonRuntime( + horizon_path=horizon_path, + ContentItem=models.ContentItem, + Config=models.Config, + StorageManager=storage.StorageManager, + HorizonOrchestrator=orchestrator.HorizonOrchestrator, + create_ai_client=ai_client.create_ai_client, + ContentAnalyzer=analyzer.ContentAnalyzer, + ContentEnricher=enricher.ContentEnricher, + DailySummarizer=summarizer.DailySummarizer, + expand_env_vars=storage._expand_env_vars, + ) + + +def load_config(runtime: HorizonRuntime, config_path: Path) -> Any: + """Load Horizon config using native pydantic model.""" + + try: + payload = runtime.expand_env_vars( + json.loads(config_path.read_text(encoding="utf-8")) + ) + return runtime.Config.model_validate(payload) + except Exception as exc: + raise HorizonMcpError( + code="HZ_CONFIG_INVALID", + message="Failed to parse config file.", + details={"config_path": str(config_path), "error": str(exc)}, + ) from exc + + +def make_storage(runtime: HorizonRuntime, config_path: Path) -> Any: + """Build Horizon storage manager bound to config's data directory.""" + + data_dir = str(config_path.parent.resolve()) + return runtime.StorageManager(data_dir=data_dir) + + +def make_orchestrator(runtime: HorizonRuntime, config: Any, storage: Any) -> Any: + """Build native Horizon orchestrator.""" + + return runtime.HorizonOrchestrator(config, storage) + + +def apply_source_filter( + config: Any, sources: list[str] | None +) -> tuple[Any, list[str], list[str]]: + """Return filtered config and source selection diagnostics.""" + + if not sources: + enabled = get_enabled_sources(config) + return config, enabled, [] + + wanted = {s.strip().lower() for s in sources if s.strip()} + unknown = sorted(wanted - VALID_SOURCES) + chosen = sorted(wanted & VALID_SOURCES) + + clone = config.model_copy(deep=True) + + if "github" not in wanted: + clone.sources.github = [] + if "hackernews" not in wanted: + clone.sources.hackernews.enabled = False + if "rss" not in wanted: + clone.sources.rss = [] + if "reddit" not in wanted: + clone.sources.reddit.enabled = False + clone.sources.reddit.subreddits = [] + clone.sources.reddit.users = [] + if "telegram" not in wanted: + clone.sources.telegram.enabled = False + clone.sources.telegram.channels = [] + if "twitter" not in wanted and getattr(clone.sources, "twitter", None): + clone.sources.twitter.enabled = False + clone.sources.twitter.users = [] + if "openbb" not in wanted and getattr(clone.sources, "openbb", None): + clone.sources.openbb.enabled = False + clone.sources.openbb.watchlists = [] + + return clone, chosen, unknown + + +def get_enabled_sources(config: Any) -> list[str]: + """List enabled top-level source types in effective config.""" + + enabled: list[str] = [] + if getattr(config.sources, "github", None): + enabled.append("github") + if getattr(config.sources.hackernews, "enabled", False): + enabled.append("hackernews") + if getattr(config.sources, "rss", None): + enabled.append("rss") + if getattr(config.sources.reddit, "enabled", False): + enabled.append("reddit") + if getattr(config.sources.telegram, "enabled", False): + enabled.append("telegram") + if getattr(getattr(config.sources, "twitter", None), "enabled", False): + enabled.append("twitter") + if getattr(getattr(config.sources, "openbb", None), "enabled", False): + enabled.append("openbb") + return enabled + + +def items_to_dicts(items: list[Any]) -> list[dict[str, Any]]: + """Serialize Horizon ContentItem models.""" + + return [item.model_dump(mode="json") for item in items] + + +def dicts_to_items(runtime: HorizonRuntime, payload: list[dict[str, Any]]) -> list[Any]: + """Deserialize ContentItem list.""" + + return [runtime.ContentItem.model_validate(item) for item in payload] + + +def get_source_counts(items: list[Any]) -> dict[str, int]: + """Count items by source type.""" + + counts: dict[str, int] = {} + for item in items: + key = item.source_type.value + counts[key] = counts.get(key, 0) + 1 + return counts + + +def _is_horizon_repo(path: Path) -> bool: + return (path / "src" / "main.py").exists() and (path / "pyproject.toml").exists() + + +def _load_mcp_secrets(horizon_path: Path, override: bool = False) -> None: + """Load MCP secrets from JSON and inject string environment variables.""" + + secrets_path = _resolve_secrets_path(horizon_path) + if not secrets_path: + return + + try: + payload = json.loads(secrets_path.read_text(encoding="utf-8")) + except Exception as exc: + raise HorizonMcpError( + code="HZ_SECRETS_INVALID", + message="Failed to parse MCP secrets file.", + details={"secrets_path": str(secrets_path), "error": str(exc)}, + ) from exc + + if not isinstance(payload, dict): + raise HorizonMcpError( + code="HZ_SECRETS_INVALID", + message="MCP secrets file must be a JSON object.", + details={"secrets_path": str(secrets_path)}, + ) + + env_payload = payload.get("env", payload) + if not isinstance(env_payload, dict): + raise HorizonMcpError( + code="HZ_SECRETS_INVALID", + message="The env field in MCP secrets must be a JSON object.", + details={"secrets_path": str(secrets_path)}, + ) + + for key, value in env_payload.items(): + if not ENV_KEY_RE.fullmatch(str(key)): + continue + if not isinstance(value, str): + raise HorizonMcpError( + code="HZ_SECRETS_INVALID", + message=f"MCP secret {key} must be a string.", + details={"secrets_path": str(secrets_path), "key": key}, + ) + if value.strip() == "": + continue + if override or not os.getenv(key): + os.environ[key] = value + + +def _resolve_secrets_path(horizon_path: Path) -> Path | None: + """Resolve secrets config path via env and common locations.""" + + explicit = os.getenv("HORIZON_MCP_SECRETS_PATH") + if explicit: + explicit_path = Path(explicit).expanduser().resolve() + if explicit_path.exists(): + return explicit_path + raise HorizonMcpError( + code="HZ_SECRETS_NOT_FOUND", + message="HORIZON_MCP_SECRETS_PATH points to a missing file.", + details={"secrets_path": str(explicit_path)}, + ) + + cwd = Path.cwd() + candidates = [ + cwd / ".cursor" / "mcp.secrets.json", + cwd / ".cursor" / "mcp.secrets.local.json", + cwd / "config" / "mcp.secrets.json", + cwd / "config" / "mcp.secrets.local.json", + horizon_path / "data" / "mcp.secrets.json", + horizon_path / "data" / "mcp-secrets.json", + ] + for candidate in candidates: + resolved = candidate.resolve() + if resolved.exists(): + return resolved + return None diff --git a/src/mcp/integration.md b/src/mcp/integration.md new file mode 100644 index 0000000..9a273c9 --- /dev/null +++ b/src/mcp/integration.md @@ -0,0 +1,99 @@ +# Horizon MCP Integration + +## Recommended Command + +Start the built-in MCP server from the Horizon repository root: + +```bash +uv run horizon-mcp +``` + +If you need a Python module fallback: + +```bash +uv run python -m src.mcp.server +``` + +## Two Setup Modes + +### Option A: Client Config With Explicit `cwd` + +Some MCP clients need a fixed working directory in their config. In that case, the absolute path is only used in the client-side `cwd` field, not in Horizon's code. + +Example: + +```json +{ + "mcpServers": { + "horizon": { + "command": "uv", + "args": ["run", "horizon-mcp"], + "cwd": "/absolute/path/to/Horizon" + } + } +} +``` + +Restart the client after saving the config. + +### Option B: Local Start Without Any Path In Config + +If your workflow allows you to start the MCP server manually, no absolute path is needed at all: + +```bash +cd /absolute/path/to/Horizon +uv run horizon-mcp +``` + +This is the cleanest way to avoid path values in client configuration. + +## Secret Files + +Instead of exporting environment variables manually, you can place a JSON file in one of these locations: + +- `.cursor/mcp.secrets.json` +- `.cursor/mcp.secrets.local.json` +- `config/mcp.secrets.json` +- `config/mcp.secrets.local.json` +- `/data/mcp.secrets.json` +- `/data/mcp-secrets.json` + +Supported formats: + +```json +{ + "OPENAI_API_KEY": "sk-xxxx", + "ANTHROPIC_API_KEY": "sk-ant-xxxx", + "GOOGLE_API_KEY": "xxxx", + "GITHUB_TOKEN": "ghp_xxxx" +} +``` + +```json +{ + "env": { + "OPENAI_API_KEY": "sk-xxxx", + "ANTHROPIC_API_KEY": "sk-ant-xxxx", + "GOOGLE_API_KEY": "xxxx", + "GITHUB_TOKEN": "ghp_xxxx" + } +} +``` + +You can also point to a custom secrets file with: + +```json +{ + "HORIZON_MCP_SECRETS_PATH": "/absolute/path/to/mcp.secrets.json" +} +``` + +## Smoke Check + +Run the local smoke check from the repository root: + +```bash +uv run python scripts/check_mcp.py +``` + +It verifies module import, path resolution, config loading, and metrics access. diff --git a/src/mcp/run_store.py b/src/mcp/run_store.py new file mode 100644 index 0000000..955a64d --- /dev/null +++ b/src/mcp/run_store.py @@ -0,0 +1,156 @@ +"""Run artifact persistence for Horizon MCP.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + + +STAGES = { + "raw": "raw_items.json", + "scored": "scored_items.json", + "filtered": "filtered_items.json", + "enriched": "enriched_items.json", +} +RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +LANGUAGE_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +@dataclass +class RunStore: + """Store intermediate artifacts per pipeline run.""" + + root: Path + + def __post_init__(self) -> None: + self.root.mkdir(parents=True, exist_ok=True) + + def create_run(self, run_id: str | None = None) -> str: + run_id = run_id or self._make_run_id() + run_dir = self._run_path(run_id) + run_dir.mkdir(parents=True, exist_ok=True) + meta_path = run_dir / "meta.json" + if not meta_path.exists(): + self.write_json( + run_id, "meta.json", {"run_id": run_id, "created_at": self._utc_now()} + ) + return run_id + + def run_dir(self, run_id: str) -> Path: + path = self._run_path(run_id) + if not path.exists(): + raise FileNotFoundError(f"Run not found: {run_id}") + return path + + def has_stage(self, run_id: str, stage: str) -> bool: + return (self.run_dir(run_id) / self._stage_file(stage)).exists() + + def save_items(self, run_id: str, stage: str, items: list[dict[str, Any]]) -> Path: + return self.write_json(run_id, self._stage_file(stage), items) + + def load_items(self, run_id: str, stage: str) -> list[dict[str, Any]]: + return self.read_json(run_id, self._stage_file(stage)) + + def save_summary(self, run_id: str, language: str, markdown: str) -> Path: + filename = self._summary_file(language) + path = self.run_dir(run_id) / filename + path.write_text(markdown, encoding="utf-8") + return path + + def load_summary(self, run_id: str, language: str) -> str: + path = self.run_dir(run_id) / self._summary_file(language) + if not path.exists(): + raise FileNotFoundError(f"Summary not found: run={run_id} lang={language}") + return path.read_text(encoding="utf-8") + + def update_meta(self, run_id: str, updates: dict[str, Any]) -> dict[str, Any]: + meta = self.read_json(run_id, "meta.json") + meta.update(updates) + meta["updated_at"] = self._utc_now() + self.write_json(run_id, "meta.json", meta) + return meta + + def load_meta(self, run_id: str) -> dict[str, Any]: + return self.read_json(run_id, "meta.json") + + def list_runs(self, limit: int = 20) -> list[dict[str, Any]]: + """List runs sorted by create/update time descending.""" + + entries: list[dict[str, Any]] = [] + for run_dir in self.root.iterdir(): + if not run_dir.is_dir(): + continue + meta_path = run_dir / "meta.json" + if not meta_path.exists(): + continue + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + + created = meta.get("created_at") or "" + updated = meta.get("updated_at") or created + entries.append( + { + "run_id": meta.get("run_id", run_dir.name), + "created_at": created, + "updated_at": updated, + "meta": meta, + } + ) + + entries.sort(key=lambda x: x["updated_at"] or x["created_at"], reverse=True) + return entries[: max(0, limit)] + + def write_json(self, run_id: str, filename: str, payload: Any) -> Path: + path = self.run_dir(run_id) / filename + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return path + + def read_json(self, run_id: str, filename: str) -> Any: + path = self.run_dir(run_id) / filename + if not path.exists(): + raise FileNotFoundError(f"Artifact not found: run={run_id} file={filename}") + return json.loads(path.read_text(encoding="utf-8")) + + @staticmethod + def _stage_file(stage: str) -> str: + if stage not in STAGES: + supported = ", ".join(sorted(STAGES)) + raise ValueError( + f"Unsupported stage '{stage}', expected one of: {supported}" + ) + return STAGES[stage] + + def _run_path(self, run_id: str) -> Path: + if not RUN_ID_RE.fullmatch(run_id) or ".." in run_id: + raise ValueError("Invalid run_id") + + root = self.root.resolve() + path = (self.root / run_id).resolve() + if not path.is_relative_to(root): + raise ValueError("Invalid run_id") + return path + + @staticmethod + def _summary_file(language: str) -> str: + if not LANGUAGE_RE.fullmatch(language): + raise ValueError("Invalid summary language") + return f"summary-{language}.md" + + @staticmethod + def _make_run_id() -> str: + now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"run-{now}-{uuid4().hex[:8]}" + + @staticmethod + def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/src/mcp/server.py b/src/mcp/server.py new file mode 100644 index 0000000..0eff6a3 --- /dev/null +++ b/src/mcp/server.py @@ -0,0 +1,504 @@ +"""MCP server entrypoint for Horizon.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from time import perf_counter +from typing import Any, Awaitable, Callable + +from mcp.server.fastmcp import FastMCP + +from .errors import HorizonMcpError +from .service import HorizonPipelineService + + +mcp = FastMCP(name="horizon-mcp") +service = HorizonPipelineService() + +SERVER_STARTED_AT = datetime.now(timezone.utc).isoformat() +METRICS: dict[str, Any] = { + "started_at": SERVER_STARTED_AT, + "tool_calls_total": 0, + "tool_calls_success": 0, + "tool_calls_failed": 0, + "tool_calls_by_name": {}, + "tool_errors_by_code": {}, + "tool_last_duration_ms": {}, + "last_error": None, +} + + +def _ok(tool: str, data: dict[str, Any], duration_ms: float | None = None) -> dict[str, Any]: + payload = { + "ok": True, + "tool": tool, + "data": data, + "meta": { + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + } + if duration_ms is not None: + payload["meta"]["duration_ms"] = round(duration_ms, 2) + return payload + + +def _err(tool: str, error: Exception, duration_ms: float | None = None) -> dict[str, Any]: + if isinstance(error, HorizonMcpError): + code = error.code + message = error.message + details = error.details + else: + code = "HZ_INTERNAL_ERROR" + message = str(error) + details = None + + payload = { + "ok": False, + "tool": tool, + "error": { + "code": code, + "message": message, + "details": details, + }, + "meta": { + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + } + if duration_ms is not None: + payload["meta"]["duration_ms"] = round(duration_ms, 2) + return payload + + +def _record_metrics(tool: str, ok: bool, duration_ms: float, error_code: str | None = None) -> None: + METRICS["tool_calls_total"] += 1 + if ok: + METRICS["tool_calls_success"] += 1 + else: + METRICS["tool_calls_failed"] += 1 + + by_name = METRICS["tool_calls_by_name"] + by_name[tool] = by_name.get(tool, 0) + 1 + + METRICS["tool_last_duration_ms"][tool] = round(duration_ms, 2) + + if error_code: + by_code = METRICS["tool_errors_by_code"] + by_code[error_code] = by_code.get(error_code, 0) + 1 + METRICS["last_error"] = { + "tool": tool, + "code": error_code, + "at": datetime.now(timezone.utc).isoformat(), + } + + +async def _run_tool(tool: str, runner: Callable[[], Awaitable[dict[str, Any]]]) -> dict[str, Any]: + started = perf_counter() + try: + data = await runner() + elapsed_ms = (perf_counter() - started) * 1000 + _record_metrics(tool, ok=True, duration_ms=elapsed_ms) + return _ok(tool, data, duration_ms=elapsed_ms) + except Exception as exc: + elapsed_ms = (perf_counter() - started) * 1000 + payload = _err(tool, exc, duration_ms=elapsed_ms) + code = payload["error"]["code"] + _record_metrics(tool, ok=False, duration_ms=elapsed_ms, error_code=code) + return payload + + +def _resource_result(resource: str, loader: Callable[[], Any]) -> dict[str, Any]: + try: + data = loader() + return { + "ok": True, + "resource": resource, + "data": data, + } + except Exception as exc: + return _err(resource, exc) + + +def _metrics_snapshot() -> dict[str, Any]: + uptime_seconds = ( + datetime.now(timezone.utc) - datetime.fromisoformat(METRICS["started_at"]) + ).total_seconds() + return { + **METRICS, + "uptime_seconds": round(uptime_seconds, 2), + } + + +@mcp.tool() +async def hz_validate_config( + horizon_path: str | None = None, + config_path: str | None = None, + sources: list[str] | None = None, + check_env: bool = True, +) -> dict[str, Any]: + """Validate Horizon config and required environment variables.""" + + return await _run_tool( + "hz_validate_config", + lambda: service.validate_config( + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + check_env=check_env, + ), + ) + + +@mcp.tool() +async def hz_fetch_items( + hours: int = 24, + run_id: str | None = None, + horizon_path: str | None = None, + config_path: str | None = None, + sources: list[str] | None = None, +) -> dict[str, Any]: + """Fetch and deduplicate content into the raw stage.""" + + return await _run_tool( + "hz_fetch_items", + lambda: service.fetch_items( + hours=hours, + run_id=run_id, + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + ), + ) + + +@mcp.tool() +async def hz_score_items( + run_id: str, + source_stage: str = "raw", + horizon_path: str | None = None, + config_path: str | None = None, +) -> dict[str, Any]: + """Score a stage into the scored stage.""" + + return await _run_tool( + "hz_score_items", + lambda: service.score_items( + run_id=run_id, + source_stage=source_stage, + horizon_path=horizon_path, + config_path=config_path, + ), + ) + + +@mcp.tool() +async def hz_filter_items( + run_id: str, + threshold: float | None = None, + source_stage: str = "scored", + topic_dedup: bool = True, + horizon_path: str | None = None, + config_path: str | None = None, +) -> dict[str, Any]: + """Filter scored items into the filtered stage.""" + + return await _run_tool( + "hz_filter_items", + lambda: service.filter_items( + run_id=run_id, + threshold=threshold, + source_stage=source_stage, + topic_dedup=topic_dedup, + horizon_path=horizon_path, + config_path=config_path, + ), + ) + + +@mcp.tool() +async def hz_enrich_items( + run_id: str, + source_stage: str = "filtered", + horizon_path: str | None = None, + config_path: str | None = None, +) -> dict[str, Any]: + """Enrich filtered items into the enriched stage.""" + + return await _run_tool( + "hz_enrich_items", + lambda: service.enrich_items( + run_id=run_id, + source_stage=source_stage, + horizon_path=horizon_path, + config_path=config_path, + ), + ) + + +@mcp.tool() +async def hz_generate_summary( + run_id: str, + language: str = "zh", + source_stage: str | None = None, + horizon_path: str | None = None, + config_path: str | None = None, + save_to_horizon_data: bool = False, +) -> dict[str, Any]: + """Generate a markdown summary from a stage.""" + + return await _run_tool( + "hz_generate_summary", + lambda: service.generate_summary( + run_id=run_id, + language=language, + source_stage=source_stage, + horizon_path=horizon_path, + config_path=config_path, + save_to_horizon_data=save_to_horizon_data, + ), + ) + + +@mcp.tool() +async def hz_run_pipeline( + hours: int = 24, + languages: list[str] | None = None, + threshold: float | None = None, + horizon_path: str | None = None, + config_path: str | None = None, + sources: list[str] | None = None, + enrich: bool = True, + topic_dedup: bool = True, + save_to_horizon_data: bool = False, +) -> dict[str, Any]: + """Run fetch -> score -> filter -> enrich -> summarize in one call.""" + + return await _run_tool( + "hz_run_pipeline", + lambda: service.run_pipeline( + hours=hours, + languages=languages, + threshold=threshold, + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + enrich=enrich, + topic_dedup=topic_dedup, + save_to_horizon_data=save_to_horizon_data, + ), + ) + + +@mcp.tool() +def hz_list_runs(limit: int = 20) -> dict[str, Any]: + """List recent runs and stage states.""" + + started = perf_counter() + try: + data = service.list_runs(limit=limit) + elapsed_ms = (perf_counter() - started) * 1000 + _record_metrics("hz_list_runs", ok=True, duration_ms=elapsed_ms) + return _ok("hz_list_runs", data, duration_ms=elapsed_ms) + except Exception as exc: + elapsed_ms = (perf_counter() - started) * 1000 + payload = _err("hz_list_runs", exc, duration_ms=elapsed_ms) + _record_metrics( + "hz_list_runs", + ok=False, + duration_ms=elapsed_ms, + error_code=payload["error"]["code"], + ) + return payload + + +@mcp.tool() +def hz_get_run_meta(run_id: str) -> dict[str, Any]: + """Read run metadata.""" + + started = perf_counter() + try: + data = service.get_run_meta(run_id) + elapsed_ms = (perf_counter() - started) * 1000 + _record_metrics("hz_get_run_meta", ok=True, duration_ms=elapsed_ms) + return _ok("hz_get_run_meta", data, duration_ms=elapsed_ms) + except Exception as exc: + elapsed_ms = (perf_counter() - started) * 1000 + payload = _err("hz_get_run_meta", exc, duration_ms=elapsed_ms) + _record_metrics( + "hz_get_run_meta", + ok=False, + duration_ms=elapsed_ms, + error_code=payload["error"]["code"], + ) + return payload + + +@mcp.tool() +def hz_get_run_stage(run_id: str, stage: str, max_items: int = 200) -> dict[str, Any]: + """Read items from a run stage.""" + + started = perf_counter() + try: + data = service.get_run_stage(run_id=run_id, stage=stage, max_items=max_items) + elapsed_ms = (perf_counter() - started) * 1000 + _record_metrics("hz_get_run_stage", ok=True, duration_ms=elapsed_ms) + return _ok("hz_get_run_stage", data, duration_ms=elapsed_ms) + except Exception as exc: + elapsed_ms = (perf_counter() - started) * 1000 + payload = _err("hz_get_run_stage", exc, duration_ms=elapsed_ms) + _record_metrics( + "hz_get_run_stage", + ok=False, + duration_ms=elapsed_ms, + error_code=payload["error"]["code"], + ) + return payload + + +@mcp.tool() +def hz_get_run_summary(run_id: str, language: str = "zh") -> dict[str, Any]: + """Read a generated run summary.""" + + started = perf_counter() + try: + data = service.get_run_summary(run_id=run_id, language=language) + elapsed_ms = (perf_counter() - started) * 1000 + _record_metrics("hz_get_run_summary", ok=True, duration_ms=elapsed_ms) + return _ok("hz_get_run_summary", data, duration_ms=elapsed_ms) + except Exception as exc: + elapsed_ms = (perf_counter() - started) * 1000 + payload = _err("hz_get_run_summary", exc, duration_ms=elapsed_ms) + _record_metrics( + "hz_get_run_summary", + ok=False, + duration_ms=elapsed_ms, + error_code=payload["error"]["code"], + ) + return payload + + +@mcp.tool() +def hz_get_metrics() -> dict[str, Any]: + """Read in-memory server metrics.""" + + started = perf_counter() + try: + data = _metrics_snapshot() + elapsed_ms = (perf_counter() - started) * 1000 + _record_metrics("hz_get_metrics", ok=True, duration_ms=elapsed_ms) + return _ok("hz_get_metrics", data, duration_ms=elapsed_ms) + except Exception as exc: + elapsed_ms = (perf_counter() - started) * 1000 + payload = _err("hz_get_metrics", exc, duration_ms=elapsed_ms) + _record_metrics( + "hz_get_metrics", + ok=False, + duration_ms=elapsed_ms, + error_code=payload["error"]["code"], + ) + return payload + + +@mcp.tool() +async def hz_send_webhook( + date: str, + language: str = "zh", + important_items: int = 0, + all_items: int = 0, + result: str = "success", + summary: str = "", + horizon_path: str | None = None, + config_path: str | None = None, +) -> dict[str, Any]: + """Send a webhook notification with the given variables. + + Uses the webhook URL (from environment variable), request_body template, + and headers from the Horizon config. Template variables #{date}, #{language}, + #{important_items}, #{all_items}, #{result}, #{timestamp}, + #{summary} are replaced in the URL and request_body before sending. + """ + + return await _run_tool( + "hz_send_webhook", + lambda: service.send_webhook( + date=date, + language=language, + important_items=important_items, + all_items=all_items, + result=result, + summary=summary, + horizon_path=horizon_path, + config_path=config_path, + ), + ) + + +@mcp.resource("horizon://server/info") +def r_server_info() -> dict[str, Any]: + """Server metadata resource.""" + + return { + "name": "horizon-mcp", + "started_at": SERVER_STARTED_AT, + "runs_root": str(service.runs_root.resolve()), + } + + +@mcp.resource("horizon://metrics") +def r_metrics() -> dict[str, Any]: + """In-memory metrics snapshot.""" + + return _resource_result("horizon://metrics", _metrics_snapshot) + + +@mcp.resource("horizon://runs") +def r_runs() -> dict[str, Any]: + """Recent run list.""" + + return _resource_result("horizon://runs", lambda: service.list_runs(limit=30)) + + +@mcp.resource("horizon://runs/{run_id}/meta") +def r_run_meta(run_id: str) -> dict[str, Any]: + """Run metadata resource.""" + + return _resource_result( + f"horizon://runs/{run_id}/meta", + lambda: service.get_run_meta(run_id), + ) + + +@mcp.resource("horizon://runs/{run_id}/items/{stage}") +def r_run_items(run_id: str, stage: str) -> dict[str, Any]: + """Run stage items resource.""" + + return _resource_result( + f"horizon://runs/{run_id}/items/{stage}", + lambda: service.get_run_stage(run_id=run_id, stage=stage, max_items=200), + ) + + +@mcp.resource("horizon://runs/{run_id}/summary/{language}") +def r_run_summary(run_id: str, language: str) -> dict[str, Any]: + """Run summary resource.""" + + return _resource_result( + f"horizon://runs/{run_id}/summary/{language}", + lambda: service.get_run_summary(run_id=run_id, language=language), + ) + + +@mcp.resource("horizon://config/effective") +def r_effective_config() -> dict[str, Any]: + """Effective default config resolved from local Horizon path.""" + + return _resource_result("horizon://config/effective", service.get_effective_config) + + +def main() -> None: + """Run MCP server over stdio.""" + + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/mcp/service.py b/src/mcp/service.py new file mode 100644 index 0000000..6e71053 --- /dev/null +++ b/src/mcp/service.py @@ -0,0 +1,681 @@ +"""Application service for staged Horizon pipeline execution.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from .errors import HorizonMcpError +from .horizon_adapter import ( + apply_source_filter, + dicts_to_items, + get_enabled_sources, + get_source_counts, + items_to_dicts, + load_config, + load_runtime, + make_orchestrator, + make_storage, + resolve_config_path, + resolve_horizon_path, +) +from .run_store import RunStore +from ..services.webhook import WebhookNotifier + + +def _default_runs_root() -> Path: + return Path(__file__).resolve().parents[2] / "data" / "mcp-runs" + + +@dataclass +class PipelineContext: + """Resolved execution context per call.""" + + horizon_path: Path + config_path: Path + runtime: Any + config: Any + + +class HorizonPipelineService: + """High-level staged pipeline service.""" + + def __init__(self, runs_root: Path | None = None): + self.runs_root = Path(runs_root).resolve() if runs_root else _default_runs_root().resolve() + self._run_store: RunStore | None = None + + @property + def run_store(self) -> RunStore: + if self._run_store is None: + self._run_store = RunStore(self.runs_root) + return self._run_store + + def list_runs(self, limit: int = 20) -> dict[str, Any]: + """List recent runs and stage availability.""" + + runs = self.run_store.list_runs(limit=limit) + items = [] + for run in runs: + run_id = run["run_id"] + stages = {} + for stage in ("raw", "scored", "filtered", "enriched"): + stages[stage] = self.run_store.has_stage(run_id, stage) + items.append( + { + "run_id": run_id, + "created_at": run.get("created_at"), + "updated_at": run.get("updated_at"), + "stages": stages, + "meta": run.get("meta", {}), + } + ) + return {"count": len(items), "items": items} + + def get_run_meta(self, run_id: str) -> dict[str, Any]: + """Read run metadata.""" + + try: + meta = self.run_store.load_meta(run_id) + except FileNotFoundError as exc: + raise HorizonMcpError( + code="HZ_RUN_NOT_FOUND", + message=f"run_id={run_id} does not exist.", + details={"run_id": run_id}, + ) from exc + return {"run_id": run_id, "meta": meta} + + def get_run_stage( + self, + run_id: str, + stage: str, + max_items: int = 200, + ) -> dict[str, Any]: + """Read staged item payload (JSON).""" + + if max_items <= 0: + raise HorizonMcpError(code="HZ_INVALID_INPUT", message="max_items must be greater than 0.") + try: + items = self.run_store.load_items(run_id, stage) + except ValueError as exc: + raise HorizonMcpError( + code="HZ_INVALID_STAGE", + message=str(exc), + details={"stage": stage}, + ) from exc + except FileNotFoundError as exc: + raise HorizonMcpError( + code="HZ_STAGE_NOT_FOUND", + message=f"run_id={run_id} is missing stage artifact: {stage}", + details={"run_id": run_id, "stage": stage}, + ) from exc + + return { + "run_id": run_id, + "stage": stage, + "count": len(items), + "items": items[:max_items], + "truncated": len(items) > max_items, + } + + def get_run_summary(self, run_id: str, language: str = "zh") -> dict[str, Any]: + """Read generated markdown summary for a run.""" + + try: + markdown = self.run_store.load_summary(run_id, language) + except FileNotFoundError as exc: + raise HorizonMcpError( + code="HZ_SUMMARY_NOT_FOUND", + message=f"run_id={run_id} is missing summary for language={language}.", + details={"run_id": run_id, "language": language}, + ) from exc + return { + "run_id": run_id, + "language": language, + "summary": markdown, + } + + def get_effective_config( + self, + horizon_path: str | None = None, + config_path: str | None = None, + sources: list[str] | None = None, + ) -> dict[str, Any]: + """Return effective config after optional source filtering.""" + + ctx, selected_sources, unknown_sources = self._build_context( + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + ) + return { + "horizon_path": str(ctx.horizon_path), + "config_path": str(ctx.config_path), + "selected_sources": selected_sources, + "unknown_sources": unknown_sources, + "config": ctx.config.model_dump(mode="json"), + } + + async def validate_config( + self, + horizon_path: str | None = None, + config_path: str | None = None, + sources: list[str] | None = None, + check_env: bool = True, + ) -> dict[str, Any]: + ctx, selected_sources, unknown_sources = self._build_context( + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + ) + + warnings: list[str] = [] + missing_env: list[str] = [] + + if check_env: + required = [ctx.config.ai.api_key_env] + for key in required: + if not os.getenv(key): + missing_env.append(key) + + if ctx.config.sources.github and not os.getenv("GITHUB_TOKEN"): + warnings.append("GITHUB_TOKEN is not set; GitHub fetching may hit strict rate limits.") + + if getattr(ctx.config, "email", None) and ctx.config.email and ctx.config.email.enabled: + pwd_key = ctx.config.email.password_env + if not os.getenv(pwd_key): + missing_env.append(pwd_key) + + if getattr(ctx.config, "webhook", None) and ctx.config.webhook and ctx.config.webhook.enabled: + if ctx.config.webhook.url_env and not os.getenv(ctx.config.webhook.url_env): + missing_env.append(ctx.config.webhook.url_env) + + return { + "horizon_path": str(ctx.horizon_path), + "config_path": str(ctx.config_path), + "ai": { + "provider": ctx.config.ai.provider.value, + "model": ctx.config.ai.model, + "languages": list(ctx.config.ai.languages), + "api_key_env": ctx.config.ai.api_key_env, + }, + "filtering": { + "ai_score_threshold": ctx.config.filtering.ai_score_threshold, + "time_window_hours": ctx.config.filtering.time_window_hours, + "max_items": ctx.config.filtering.max_items, + "category_groups": { + key: group.model_dump(mode="json") + for key, group in ctx.config.filtering.category_groups.items() + }, + "default_group": ctx.config.filtering.default_group, + "default_group_limit": ctx.config.filtering.default_group_limit, + }, + "enabled_sources": get_enabled_sources(ctx.config), + "selected_sources": selected_sources, + "unknown_sources": unknown_sources, + "missing_env": missing_env, + "warnings": warnings, + } + + async def fetch_items( + self, + hours: int = 24, + run_id: str | None = None, + horizon_path: str | None = None, + config_path: str | None = None, + sources: list[str] | None = None, + ) -> dict[str, Any]: + if hours <= 0: + raise HorizonMcpError(code="HZ_INVALID_INPUT", message="hours must be greater than 0.") + + ctx, selected_sources, unknown_sources = self._build_context( + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + ) + + storage = make_storage(ctx.runtime, ctx.config_path) + orchestrator = make_orchestrator(ctx.runtime, ctx.config, storage) + + run_id = self.run_store.create_run(run_id) + since = datetime.now(timezone.utc) - timedelta(hours=hours) + + raw_items = await orchestrator.fetch_all_sources(since) + merged_items = orchestrator.merge_cross_source_duplicates(raw_items) + + self.run_store.save_items(run_id, "raw", items_to_dicts(merged_items)) + meta = self.run_store.update_meta( + run_id, + { + "horizon_path": str(ctx.horizon_path), + "config_path": str(ctx.config_path), + "hours": hours, + "since": since.isoformat(), + "source_selection": selected_sources, + "unknown_sources": unknown_sources, + "raw_count_before_merge": len(raw_items), + "raw_count": len(merged_items), + }, + ) + + return { + "run_id": run_id, + "fetched": len(merged_items), + "raw_before_merge": len(raw_items), + "source_counts": get_source_counts(merged_items), + "artifact": str((self.run_store.run_dir(run_id) / "raw_items.json").resolve()), + "meta": meta, + } + + async def score_items( + self, + run_id: str, + source_stage: str = "raw", + horizon_path: str | None = None, + config_path: str | None = None, + ) -> dict[str, Any]: + items, ctx = self._load_stage_items( + run_id=run_id, + stage=source_stage, + horizon_path=horizon_path, + config_path=config_path, + ) + + if not items: + raise HorizonMcpError(code="HZ_EMPTY_INPUT", message="No items available for scoring.") + + ai_client = ctx.runtime.create_ai_client(ctx.config.ai) + analyzer = ctx.runtime.ContentAnalyzer(ai_client) + scored_items = await analyzer.analyze_batch(items) + + self.run_store.save_items(run_id, "scored", items_to_dicts(scored_items)) + score_threshold = ctx.config.filtering.ai_score_threshold + above_threshold = [x for x in scored_items if x.ai_score and x.ai_score >= score_threshold] + + meta = self.run_store.update_meta( + run_id, + { + "scored_count": len(scored_items), + "scored_threshold": score_threshold, + "scored_above_threshold": len(above_threshold), + }, + ) + + return { + "run_id": run_id, + "scored": len(scored_items), + "above_threshold": len(above_threshold), + "score_distribution": self._score_distribution(scored_items), + "artifact": str((self.run_store.run_dir(run_id) / "scored_items.json").resolve()), + "meta": meta, + } + + async def filter_items( + self, + run_id: str, + threshold: float | None = None, + source_stage: str = "scored", + topic_dedup: bool = True, + horizon_path: str | None = None, + config_path: str | None = None, + ) -> dict[str, Any]: + items, ctx = self._load_stage_items( + run_id=run_id, + stage=source_stage, + horizon_path=horizon_path, + config_path=config_path, + ) + + effective_threshold = threshold if threshold is not None else ctx.config.filtering.ai_score_threshold + + important_items = [item for item in items if item.ai_score and item.ai_score >= effective_threshold] + important_items.sort(key=lambda x: x.ai_score or 0, reverse=True) + + before_dedup = len(important_items) + orchestrator = None + if topic_dedup and important_items: + storage = make_storage(ctx.runtime, ctx.config_path) + orchestrator = make_orchestrator(ctx.runtime, ctx.config, storage) + important_items = await orchestrator.merge_topic_duplicates(important_items) + after_dedup = len(important_items) + + filtering = ctx.config.filtering + balanced_enabled = bool( + getattr(filtering, "category_groups", {}) + or getattr(filtering, "max_items", None) is not None + ) + balanced_group_counts: dict[str, int] = {} + if balanced_enabled: + if orchestrator is None: + storage = make_storage(ctx.runtime, ctx.config_path) + orchestrator = make_orchestrator(ctx.runtime, ctx.config, storage) + balanced_result = orchestrator.apply_balanced_digest( + important_items, + log=False, + ) + important_items = balanced_result.items + balanced_group_counts = balanced_result.group_counts + + self.run_store.save_items(run_id, "filtered", items_to_dicts(important_items)) + meta = self.run_store.update_meta( + run_id, + { + "filtered_count": len(important_items), + "filter_threshold": effective_threshold, + "topic_dedup_enabled": topic_dedup, + "topic_dedup_removed": before_dedup - after_dedup, + "balanced_digest_enabled": balanced_enabled, + "balanced_digest_group_counts": balanced_group_counts, + "balanced_digest_removed": after_dedup - len(important_items), + }, + ) + + return { + "run_id": run_id, + "kept": len(important_items), + "threshold": effective_threshold, + "removed_by_topic_dedup": before_dedup - after_dedup, + "removed_by_balanced_digest": after_dedup - len(important_items), + "balanced_digest_enabled": balanced_enabled, + "group_counts": balanced_group_counts, + "source_counts": get_source_counts(important_items), + "artifact": str((self.run_store.run_dir(run_id) / "filtered_items.json").resolve()), + "meta": meta, + } + + async def enrich_items( + self, + run_id: str, + source_stage: str = "filtered", + horizon_path: str | None = None, + config_path: str | None = None, + ) -> dict[str, Any]: + items, ctx = self._load_stage_items( + run_id=run_id, + stage=source_stage, + horizon_path=horizon_path, + config_path=config_path, + ) + + if not items: + raise HorizonMcpError(code="HZ_EMPTY_INPUT", message="No items available for enrichment.") + + ai_client = ctx.runtime.create_ai_client(ctx.config.ai) + enricher = ctx.runtime.ContentEnricher(ai_client) + await enricher.enrich_batch(items) + + self.run_store.save_items(run_id, "enriched", items_to_dicts(items)) + + citation_count = 0 + for item in items: + citation_count += len(item.metadata.get("sources", [])) + + meta = self.run_store.update_meta( + run_id, + { + "enriched_count": len(items), + "citation_count": citation_count, + }, + ) + + return { + "run_id": run_id, + "enriched": len(items), + "citation_count": citation_count, + "artifact": str((self.run_store.run_dir(run_id) / "enriched_items.json").resolve()), + "meta": meta, + } + + async def generate_summary( + self, + run_id: str, + language: str = "zh", + source_stage: str | None = None, + horizon_path: str | None = None, + config_path: str | None = None, + save_to_horizon_data: bool = False, + ) -> dict[str, Any]: + stage = source_stage or self._pick_summary_stage(run_id) + items, ctx = self._load_stage_items( + run_id=run_id, + stage=stage, + horizon_path=horizon_path, + config_path=config_path, + ) + + total_fetched = self._total_fetched(run_id, fallback=len(items)) + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + summarizer = ctx.runtime.DailySummarizer() + summary = await summarizer.generate_summary( + items, + date_str, + total_fetched, + language=language, + ) + + run_summary_path = self.run_store.save_summary(run_id, language, summary) + published_path = None + if save_to_horizon_data: + storage = make_storage(ctx.runtime, ctx.config_path) + published_path = storage.save_daily_summary(date_str, summary, language=language) + + summary_meta = { + "summary_stage": stage, + "summary_language": language, + "summary_generated_at": datetime.now(timezone.utc).isoformat(), + "summary_artifact": str(run_summary_path.resolve()), + } + if published_path: + summary_meta["summary_published_path"] = str(Path(published_path).resolve()) + meta = self.run_store.update_meta(run_id, summary_meta) + + return { + "run_id": run_id, + "language": language, + "source_stage": stage, + "total_fetched": total_fetched, + "items_used": len(items), + "summary_path": str(run_summary_path.resolve()), + "published_path": str(Path(published_path).resolve()) if published_path else None, + "preview": summary[:1200], + "meta": meta, + } + + async def run_pipeline( + self, + hours: int = 24, + languages: list[str] | None = None, + threshold: float | None = None, + horizon_path: str | None = None, + config_path: str | None = None, + sources: list[str] | None = None, + enrich: bool = True, + topic_dedup: bool = True, + save_to_horizon_data: bool = False, + ) -> dict[str, Any]: + fetch_result = await self.fetch_items( + hours=hours, + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + ) + run_id = fetch_result["run_id"] + + score_result = await self.score_items( + run_id=run_id, + horizon_path=horizon_path, + config_path=config_path, + ) + + filter_result = await self.filter_items( + run_id=run_id, + threshold=threshold, + topic_dedup=topic_dedup, + horizon_path=horizon_path, + config_path=config_path, + ) + + enrich_result: dict[str, Any] | None = None + stage_for_summary = "filtered" + if enrich: + enrich_result = await self.enrich_items( + run_id=run_id, + source_stage="filtered", + horizon_path=horizon_path, + config_path=config_path, + ) + stage_for_summary = "enriched" + + ctx, _, _ = self._build_context( + horizon_path=horizon_path, + config_path=config_path, + sources=sources, + ) + final_languages = languages if languages else list(ctx.config.ai.languages) + + summaries = [] + for lang in final_languages: + summary_result = await self.generate_summary( + run_id=run_id, + language=lang, + source_stage=stage_for_summary, + horizon_path=horizon_path, + config_path=config_path, + save_to_horizon_data=save_to_horizon_data, + ) + summaries.append(summary_result) + + return { + "run_id": run_id, + "fetch": fetch_result, + "score": score_result, + "filter": filter_result, + "enrich": enrich_result, + "summaries": summaries, + "meta": self.run_store.load_meta(run_id), + } + + def _build_context( + self, + horizon_path: str | None, + config_path: str | None, + sources: list[str] | None, + ) -> tuple[PipelineContext, list[str], list[str]]: + resolved_horizon = resolve_horizon_path(horizon_path) + runtime = load_runtime(resolved_horizon) + resolved_config = resolve_config_path(resolved_horizon, config_path) + config = load_config(runtime, resolved_config) + effective_config, selected_sources, unknown_sources = apply_source_filter(config, sources) + + return ( + PipelineContext( + horizon_path=resolved_horizon, + config_path=resolved_config, + runtime=runtime, + config=effective_config, + ), + selected_sources, + unknown_sources, + ) + + def _load_stage_items( + self, + run_id: str, + stage: str, + horizon_path: str | None, + config_path: str | None, + ) -> tuple[list[Any], PipelineContext]: + ctx, _, _ = self._build_context(horizon_path=horizon_path, config_path=config_path, sources=None) + try: + payload = self.run_store.load_items(run_id, stage) + except FileNotFoundError as exc: + raise HorizonMcpError( + code="HZ_STAGE_NOT_FOUND", + message=f"run_id={run_id} is missing stage artifact: {stage}", + details={"run_id": run_id, "stage": stage}, + ) from exc + items = dicts_to_items(ctx.runtime, payload) + return items, ctx + + def _pick_summary_stage(self, run_id: str) -> str: + for stage in ("enriched", "filtered", "scored", "raw"): + if self.run_store.has_stage(run_id, stage): + return stage + raise HorizonMcpError( + code="HZ_STAGE_NOT_FOUND", + message=f"run_id={run_id} has no usable stage for summary generation.", + details={"run_id": run_id}, + ) + + def _total_fetched(self, run_id: str, fallback: int) -> int: + try: + raw = self.run_store.load_items(run_id, "raw") + return len(raw) + except Exception: + return fallback + + @staticmethod + def _score_distribution(items: list[Any]) -> dict[str, int]: + buckets = {"0-2": 0, "3-4": 0, "5-6": 0, "7-8": 0, "9-10": 0} + for item in items: + score = float(item.ai_score or 0.0) + if score < 3: + buckets["0-2"] += 1 + elif score < 5: + buckets["3-4"] += 1 + elif score < 7: + buckets["5-6"] += 1 + elif score < 9: + buckets["7-8"] += 1 + else: + buckets["9-10"] += 1 + return buckets + + async def send_webhook( + self, + date: str, + language: str = "zh", + important_items: int = 0, + all_items: int = 0, + result: str = "success", + summary: str = "", + horizon_path: str | None = None, + config_path: str | None = None, + ) -> dict[str, Any]: + """Send a webhook notification using the configured webhook settings.""" + + ctx, _, _ = self._build_context( + horizon_path=horizon_path, + config_path=config_path, + sources=None, + ) + + webhook_config = ctx.config.webhook + if not webhook_config or not webhook_config.enabled: + return { + "sent": False, + "reason": "Webhook is not enabled in configuration.", + } + + notifier = WebhookNotifier(webhook_config) + variables = { + "date": date, + "language": language, + "important_items": important_items, + "all_items": all_items, + "result": result, + "timestamp": str(int(datetime.now(timezone.utc).timestamp())), + "message_title": f"Horizon {date} webhook", + "message_kind": "manual", + "summary": summary, + } + + await notifier.notify(variables) + + return { + "sent": True, + "variables": {k: (v if k != "summary" else f"<{len(v)} chars>") for k, v in variables.items()}, + } diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..de57cdf --- /dev/null +++ b/src/models.py @@ -0,0 +1,457 @@ +"""Core data models for Horizon.""" + +from datetime import datetime, timezone +from enum import Enum +from typing import Annotated, Literal, Optional, List, Dict, Any, Union +from pydantic import BaseModel, HttpUrl, Field, field_validator + + +class SourceType(str, Enum): + """Supported information source types.""" + + GITHUB = "github" + HACKERNEWS = "hackernews" + RSS = "rss" + REDDIT = "reddit" + TELEGRAM = "telegram" + TWITTER = "twitter" + OPENBB = "openbb" + OSSINSIGHT = "ossinsight" + GDELT = "gdelt" + GOOGLE_NEWS = "google_news" + + +class ContentItem(BaseModel): + """Unified content item model from any source.""" + + id: str # Format: {source}:{subtype}:{native_id} + source_type: SourceType + title: str + url: HttpUrl + content: Optional[str] = None + author: Optional[str] = None + published_at: datetime + fetched_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + metadata: Dict[str, Any] = Field(default_factory=dict) + + # AI analysis results + ai_score: Optional[float] = None # 0-10 importance score + ai_reason: Optional[str] = None + ai_summary: Optional[str] = None + ai_tags: List[str] = Field(default_factory=list) + + +class AIProvider(str, Enum): + """Supported AI providers.""" + + ANTHROPIC = "anthropic" + OPENAI = "openai" + AZURE = "azure" + ALI = "ali" + GEMINI = "gemini" + DOUBAO = "doubao" + MINIMAX = "minimax" + DEEPSEEK = "deepseek" + OLLAMA = "ollama" + + +# Default models and API key env vars for each provider +AI_PROVIDER_DEFAULTS = { + AIProvider.ANTHROPIC: { + "model": "claude-3-5-sonnet-20241022", + "api_key_env": "ANTHROPIC_API_KEY", + }, + AIProvider.OPENAI: { + "model": "gpt-4", + "api_key_env": "OPENAI_API_KEY", + }, + AIProvider.AZURE: { + "model": "gpt-4", + "api_key_env": "AZURE_OPENAI_API_KEY", + }, + AIProvider.ALI: { + "model": "qwen-plus", + "api_key_env": "DASHSCOPE_API_KEY", + }, + AIProvider.GEMINI: { + "model": "gemini-1.5-flash", + "api_key_env": "GOOGLE_API_KEY", + }, + AIProvider.DOUBAO: { + "model": "doubao-pro-32k", + "api_key_env": "DOUBAO_API_KEY", + }, + AIProvider.MINIMAX: { + "model": "MiniMax-Text-01", + "api_key_env": "MINIMAX_API_KEY", + }, + AIProvider.DEEPSEEK: { + "model": "deepseek-chat", + "api_key_env": "DEEPSEEK_API_KEY", + }, + AIProvider.OLLAMA: { + "model": "llama3.1", + "api_key_env": "", + }, +} + + +class AIConfig(BaseModel): + """AI client configuration.""" + + provider: AIProvider + provider_chain: Optional[str] = None + model: str + base_url: Optional[str] = None + api_key_env: str + temperature: float = 0.3 + max_tokens: int = 4096 + throttle_sec: float = 0.0 + analysis_concurrency: int = 1 + enrichment_concurrency: int = 1 + languages: List[str] = Field(default_factory=lambda: ["en"]) + # Azure OpenAI specific; required when provider == AZURE + azure_endpoint_env: Optional[str] = None + api_version: Optional[str] = None + + +class GitHubSourceConfig(BaseModel): + """GitHub source configuration.""" + + type: str # "user_events", "repo_releases", etc. + username: Optional[str] = None + owner: Optional[str] = None + repo: Optional[str] = None + enabled: bool = True + category: Optional[str] = None + + +class HackerNewsConfig(BaseModel): + """Hacker News configuration.""" + + enabled: bool = True + fetch_top_stories: int = 30 + min_score: int = 100 + category: Optional[str] = None + + +class ExtractorType(str, Enum): + TRAFILATURA = "trafilatura" + + +class TrafilaturaExtractorConfig(BaseModel): + type: Literal[ExtractorType.TRAFILATURA] = ExtractorType.TRAFILATURA + favor_precision: bool = False + favor_recall: bool = False + + +ExtractorConfig = Annotated[ + Union[TrafilaturaExtractorConfig], + Field(discriminator="type"), +] + + +class RSSSourceConfig(BaseModel): + """RSS feed source configuration.""" + + name: str + url: HttpUrl + enabled: bool = True + category: Optional[str] = None + content_extractor: Optional[str] = None + + +class RedditSubredditConfig(BaseModel): + """Configuration for monitoring a specific subreddit.""" + + subreddit: str + enabled: bool = True + sort: str = "hot" # hot, new, top, rising + time_filter: str = ( + "day" # hour, day, week, month, year, all (only for top/controversial) + ) + fetch_limit: int = 25 + min_score: int = 10 + category: Optional[str] = None + + +class RedditUserConfig(BaseModel): + """Configuration for monitoring a specific Reddit user.""" + + username: str # without u/ prefix + enabled: bool = True + sort: str = "new" + fetch_limit: int = 10 + category: Optional[str] = None + + +class RedditConfig(BaseModel): + """Reddit source configuration.""" + + enabled: bool = True + subreddits: List[RedditSubredditConfig] = Field(default_factory=list) + users: List[RedditUserConfig] = Field(default_factory=list) + fetch_comments: int = 5 # top comments per post, 0 to disable + + +class TelegramChannelConfig(BaseModel): + """Configuration for monitoring a specific Telegram channel.""" + + channel: str # channel username, e.g. "zaihuapd" + enabled: bool = True + fetch_limit: int = 20 + category: Optional[str] = None + + +class TelegramConfig(BaseModel): + """Telegram source configuration.""" + + enabled: bool = True + channels: List[TelegramChannelConfig] = Field(default_factory=list) + + +class TwitterConfig(BaseModel): + """Twitter source configuration. + + Two modes are supported: + - "apify": Use Apify scweet actor (requires APIFY_TOKEN, more reliable) + - "playwright": Use Playwright + browser cookies (free, no token needed) + """ + + enabled: bool = True + mode: str = "apify" # "apify" or "playwright" + users: List[str] = Field(default_factory=list) + fetch_limit: int = 10 + category: Optional[str] = None + fetch_reply_text: bool = False + max_replies_per_tweet: int = 3 + max_tweets_to_expand: int = 10 + reply_min_likes: int = 0 + # Apify settings (used when mode == "apify") + apify_token_env: str = "APIFY_TOKEN" + actor_id: str = "altimis~scweet" + # Playwright settings (used when mode == "playwright") + cookie_dir: str = "data" + cookie_file_pattern: str = "x_cookies_*.json" + + +class OpenBBWatchlist(BaseModel): + """A named watchlist of tickers fetched from one OpenBB provider. + + Each watchlist produces one news.company() call per run, so group + symbols by provider rather than creating one watchlist per symbol. + """ + + name: str + symbols: List[str] = Field(default_factory=list) + enabled: bool = True + provider: str = "yfinance" + fetch_limit: int = 20 + category: Optional[str] = None + + +class OpenBBConfig(BaseModel): + """OpenBB Platform source configuration. + + Uses the installed `openbb` SDK to fetch news and filings for a set of + tickers. The SDK is an optional dependency; if it is not installed the + scraper will no-op with a console warning rather than crash the run. + + Provider credentials (FMP, Benzinga, Polygon, Intrinio, Tiingo, etc.) + are resolved by openbb from environment variables / its own user + settings file, so Horizon does not need to pass them explicitly. + """ + + enabled: bool = True + watchlists: List[OpenBBWatchlist] = Field(default_factory=list) + fetch_filings: bool = False + filings_provider: str = "sec" + + +class OSSInsightConfig(BaseModel): + """OSS Insight trending repos source configuration. + + Pulls top star-gain repositories from the OSS Insight public API and + emits them as ContentItems. Optional `keywords` filter limits results + to repos whose description, repo name, or collection names contain at + least one of the listed substrings (case-insensitive). Leave + `keywords` empty to ingest everything trending in the configured + languages. + """ + + enabled: bool = False + period: str = "past_24_hours" # past_24_hours, past_28_days + languages: List[str] = Field( + default_factory=lambda: ["All", "Python", "TypeScript"] + ) + keywords: List[str] = Field(default_factory=list) + min_stars: int = 5 + max_items: int = 30 + category: Optional[str] = None + + +class GDELTConfig(BaseModel): + """GDELT 2.0 DOC API source configuration. + + Queries the key-less GDELT DOC API + (https://api.gdeltproject.org/api/v2/doc/doc) for recent news articles + matching a search query and emits them as ContentItems. No API key is + required. The DOC API caps results at 250 records per request, so keep + `max_records` modest. + """ + + enabled: bool = False + query: str = "artificial intelligence" + mode: str = "ArtList" + max_records: int = 75 # GDELT DOC API caps at 250; keep modest + timespan: Optional[str] = None # e.g. "24h"; overrides since-derived window + language: Optional[str] = None # sourcelang filter, e.g. "english"; None = no filter + country: Optional[str] = None # sourcecountry filter; None = no filter + category: Optional[str] = None # Horizon category label for downstream grouping + + +class GoogleNewsConfig(BaseModel): + """Google News RSS search source configuration. + + Builds Google News RSS search URLs + (https://news.google.com/rss/search) for a query and parses the + resulting feed via feedparser. No API key is required. + """ + + enabled: bool = False + query: str = "artificial intelligence" + language: str = "en" # hl + country: str = "US" # gl + ceid: Optional[str] = None # when None scraper derives it as "{country}:{language}" + max_results: int = 100 # cap ~100 + category: Optional[str] = None + + +class SourcesConfig(BaseModel): + """All sources configuration.""" + + github: List[GitHubSourceConfig] = Field(default_factory=list) + hackernews: HackerNewsConfig = Field(default_factory=HackerNewsConfig) + rss: List[RSSSourceConfig] = Field(default_factory=list) + reddit: RedditConfig = Field(default_factory=RedditConfig) + telegram: TelegramConfig = Field(default_factory=TelegramConfig) + twitter: Optional[TwitterConfig] = None + openbb: Optional[OpenBBConfig] = None + ossinsight: OSSInsightConfig = Field(default_factory=OSSInsightConfig) + gdelt: Optional[GDELTConfig] = None + google_news: Optional[GoogleNewsConfig] = None + + +class WebhookConfig(BaseModel): + """Webhook notification configuration.""" + + url_env: Optional[str] = ( + None # Environment variable name containing the webhook URL + ) + request_body: Optional[Union[str, dict, list]] = ( + None # POST body: real JSON object or string with #{key} placeholders; if empty, will use GET + ) + headers: Optional[str] = None # Custom headers, "Key: Value" per line + delivery: str = "summary" # summary, or summary_and_items + overview_position: str = "first" # For summary_and_items: first, or last + platform: str = "generic" # generic, feishu, lark, dingtalk, slack, discord + layout: str = "markdown" # markdown, or collapsible + fallback_layout: str = ( + "markdown" # Layout to use when the requested layout is unsupported + ) + languages: Optional[List[str]] = ( + None # Optional language filter for webhook delivery; defaults to all AI languages + ) + enabled: bool = False + + @field_validator("delivery") + @classmethod + def validate_delivery(cls, v: str) -> str: + allowed = {"summary", "summary_and_items"} + if v not in allowed: + raise ValueError(f"webhook.delivery must be one of {allowed}, got '{v}'") + return v + + @field_validator("platform") + @classmethod + def validate_platform(cls, v: str) -> str: + allowed = {"generic", "feishu", "lark", "dingtalk", "slack", "discord"} + if v not in allowed: + raise ValueError(f"webhook.platform must be one of {allowed}, got '{v}'") + return v + + @field_validator("layout") + @classmethod + def validate_layout(cls, v: str) -> str: + allowed = {"markdown", "collapsible"} + if v not in allowed: + raise ValueError(f"webhook.layout must be one of {allowed}, got '{v}'") + return v + + @field_validator("fallback_layout") + @classmethod + def validate_fallback_layout(cls, v: str) -> str: + allowed = {"markdown", "collapsible"} + if v not in allowed: + raise ValueError( + f"webhook.fallback_layout must be one of {allowed}, got '{v}'" + ) + return v + + @field_validator("overview_position") + @classmethod + def validate_overview_position(cls, v: str) -> str: + allowed = {"first", "last"} + if v not in allowed: + raise ValueError( + f"webhook.overview_position must be one of {allowed}, got '{v}'" + ) + return v + + +class EmailConfig(BaseModel): + """Email configuration for updates/subscriptions.""" + + imap_server: str + imap_port: int = 993 + imap_enabled: bool = True + smtp_server: str + smtp_port: int = 465 + smtp_username: Optional[str] = None + email_address: str + password_env: str = "EMAIL_PASSWORD" + sender_name: str = "Horizon Daily" + subscribe_keyword: str = "SUBSCRIBE" + unsubscribe_keyword: str = "UNSUBSCRIBE" + enabled: bool = False + + +class CategoryGroupConfig(BaseModel): + """A quota group containing one or more source categories.""" + + name: Optional[str] = None + limit: int = Field(gt=0) + categories: List[str] = Field(min_length=1) + + +class FilteringConfig(BaseModel): + """Content filtering configuration.""" + + ai_score_threshold: float = 7.0 + time_window_hours: int = 24 + max_items: Optional[int] = Field(default=None, gt=0) + category_groups: Dict[str, CategoryGroupConfig] = Field(default_factory=dict) + default_group: str = "other" + default_group_limit: Optional[int] = Field(default=None, gt=0) + + +class Config(BaseModel): + """Main configuration model.""" + + version: str = "1.0" + ai: AIConfig + sources: SourcesConfig + filtering: FilteringConfig + extractors: Dict[str, ExtractorConfig] = Field(default_factory=dict) + email: Optional[EmailConfig] = None + webhook: Optional[WebhookConfig] = None diff --git a/src/orchestrator.py b/src/orchestrator.py new file mode 100644 index 0000000..7a2dbb1 --- /dev/null +++ b/src/orchestrator.py @@ -0,0 +1,732 @@ +"""Main orchestrator coordinating the entire workflow.""" + +import asyncio +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Optional +from urllib.parse import urlparse +import httpx +from rich.console import Console + +from .models import Config, ContentItem +from .storage.manager import StorageManager +from .services.email import EmailManager +from .services.webhook import WebhookNotifier +from .scrapers.github import GitHubScraper +from .scrapers.hackernews import HackerNewsScraper +from .scrapers.rss import RSSScraper +from .scrapers.reddit import RedditScraper +from .scrapers.telegram import TelegramScraper +from .scrapers.twitter import TwitterScraper +from .scrapers.twitter_playwright import TwitterPlaywrightScraper +from .scrapers.openbb import OpenBBScraper +from .scrapers.ossinsight import OSSInsightScraper +from .scrapers.gdelt import GDELTScraper +from .scrapers.google_news import GoogleNewsScraper +from .ai.client import create_ai_client +from .ai.analyzer import ContentAnalyzer +from .ai.summarizer import DailySummarizer +from .ai.enricher import ContentEnricher +from .ai.tokens import get_usage_snapshot + + +@dataclass +class BalancedDigestResult: + """Items and selection statistics from balanced digest filtering.""" + + items: List[ContentItem] + enabled: bool = False + group_counts: Dict[str, int] = field(default_factory=dict) + group_limits: Dict[str, Optional[int]] = field(default_factory=dict) + duplicate_categories: List[str] = field(default_factory=list) + + +class HorizonOrchestrator: + """Orchestrates the complete workflow for content aggregation and analysis.""" + + def __init__(self, config: Config, storage: StorageManager): + """Initialize orchestrator. + + Args: + config: Application configuration + storage: Storage manager + """ + self.config = config + self.storage = storage + self.console = Console() + self.email_manager = EmailManager(config.email, console=self.console) if config.email else None + self.webhook_notifier = ( + WebhookNotifier(config.webhook, console=self.console) + if config.webhook and config.webhook.enabled + else None + ) + + async def run(self, force_hours: int = None) -> None: + """Execute the complete workflow. + + Args: + force_hours: Optional override for time window in hours + """ + self.console.print("[bold cyan]🌅 Horizon - Starting aggregation...[/bold cyan]\n") + + # Check email subscriptions if configured + if ( + self.email_manager + and self.config.email + and self.config.email.enabled + and self.config.email.imap_enabled + ): + self.console.print("📧 Checking for new email subscriptions...") + self.email_manager.check_subscriptions(self.storage) + + try: + # 1. Determine time window + since = self._determine_time_window(force_hours) + self.console.print(f"📅 Fetching content since: {since.strftime('%Y-%m-%d %H:%M:%S')}\n") + + # 2. Fetch content from all sources + all_items = await self.fetch_all_sources(since) + self.console.print(f"📥 Fetched {len(all_items)} items from all sources\n") + + if not all_items: + self.console.print("[yellow]No new content found. Exiting.[/yellow]") + return + + # 3. Merge cross-source duplicates (same URL from different sources) + merged_items = self.merge_cross_source_duplicates(all_items) + if len(merged_items) < len(all_items): + self.console.print( + f"🔗 Merged {len(all_items) - len(merged_items)} cross-source duplicates " + f"→ {len(merged_items)} unique items\n" + ) + + # 4. Analyze with AI + analyzed_items = await self._analyze_content(merged_items) + self.console.print(f"🤖 Analyzed {len(analyzed_items)} items with AI\n") + + # 5. Filter by score threshold + threshold = self.config.filtering.ai_score_threshold + important_items = [ + item for item in analyzed_items + if item.ai_score and item.ai_score >= threshold + ] + important_items.sort(key=lambda x: x.ai_score or 0, reverse=True) + + self.console.print( + f"⭐️ {len(important_items)} items scored ≥ {threshold}\n" + ) + + # 5.5 Semantic deduplication: drop items covering the same topic + deduped_items = await self.merge_topic_duplicates(important_items) + if len(deduped_items) < len(important_items): + self.console.print( + f"🧹 Removed {len(important_items) - len(deduped_items)} topic duplicates " + f"→ {len(deduped_items)} unique items\n" + ) + important_items = deduped_items + + # 5.6 Optional second-stage Twitter reply expansion + targeted re-analysis + await self._expand_twitter_discussion(important_items) + + # 5.7 Apply per-category and global digest limits before enrichment + balanced_result = self.apply_balanced_digest(important_items) + important_items = balanced_result.items + + # Show per-sub-source selection breakdown + selected_counts: Dict[str, int] = defaultdict(int) + for item in important_items: + key = f"{item.source_type.value}/{self._sub_source_label(item)}" + selected_counts[key] += 1 + for source_key, count in sorted(selected_counts.items()): + self.console.print(f" • {source_key}: {count}") + self.console.print("") + + # 6. Search related stories + enrich with background knowledge (2nd AI pass) + await self._enrich_important_items(important_items) + + # 7. Generate and save daily summaries for each configured language + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + for lang in self.config.ai.languages: + summarizer = DailySummarizer() + summary = await summarizer.generate_summary(important_items, today, len(all_items), language=lang) + + # Save to data/summaries/ + summary_path = self.storage.save_daily_summary(today, summary, language=lang) + self.console.print(f"💾 Saved {lang.upper()} summary to: {summary_path}\n") + + # Copy to docs/ for GitHub Pages + try: + from pathlib import Path + + post_filename = f"{today}-summary-{lang}.md" + posts_dir = Path("docs/_posts") + posts_dir.mkdir(parents=True, exist_ok=True) + + dest_path = posts_dir / post_filename + + # Add Jekyll front matter + front_matter = ( + "---\n" + "layout: default\n" + f"title: \"Horizon Summary: {today} ({lang.upper()})\"\n" + f"date: {today}\n" + f"lang: {lang}\n" + "---\n\n" + ) + + # Strip leading H1 header to avoid duplication with Jekyll title + summary_content = summary + first_line = summary_content.strip().split("\n")[0] + if first_line.startswith("# "): + parts = summary_content.split("\n", 1) + if len(parts) > 1: + summary_content = parts[1].strip() + + with open(dest_path, "w", encoding="utf-8") as f: + f.write(front_matter + summary_content) + + self.console.print(f"📄 Copied {lang.upper()} summary to GitHub Pages: {dest_path}\n") + except Exception as e: + self.console.print(f"[yellow]⚠️ Failed to copy {lang.upper()} summary to docs/: {e}[/yellow]\n") + + # Send email if configured + if self.email_manager and self.config.email and self.config.email.enabled: + self.console.print(f"📧 Sending {lang.upper()} email summary...") + subscribers = self.storage.load_subscribers() + subject = f"Horizon Summary ({lang.upper()}) - {today}" + self.email_manager.send_daily_summary(summary, subject, subscribers) + + # Send webhook notification if configured + if self.webhook_notifier: + await self.webhook_notifier.send_daily_summary( + summary=summary, + important_items=important_items, + all_items_count=len(all_items), + date=today, + lang=lang, + summarizer=summarizer, + ) + + self.console.print("[bold green]✅ Horizon completed successfully![/bold green]") + usage = get_usage_snapshot() + if usage.total_tokens > 0: + self.console.print( + f"\n🧮 Token usage this run: " + f"{usage.total_tokens} tokens " + f"(input: {usage.total_input_tokens}, output: {usage.total_output_tokens})" + ) + for provider, u in sorted(usage.per_provider.items()): + if u.total <= 0: + continue + self.console.print( + f" • {provider}: {u.total} tokens " + f"(in: {u.input_tokens}, out: {u.output_tokens})" + ) + + except Exception as e: + self.console.print(f"[bold red]❌ Error: {e}[/bold red]") + + # Send webhook failure notification if configured + if self.webhook_notifier: + await self.webhook_notifier.send_failure( + date=datetime.now(timezone.utc).strftime("%Y-%m-%d"), + error_message=str(e), + ) + + raise + + def _determine_time_window(self, force_hours: int = None) -> datetime: + if force_hours: + since = datetime.now(timezone.utc) - timedelta(hours=force_hours) + else: + hours = self.config.filtering.time_window_hours + since = datetime.now(timezone.utc) - timedelta(hours=hours) + return since + + async def fetch_all_sources(self, since: datetime) -> List[ContentItem]: + """Fetch content from all configured sources. + + This is a stable stage entry point for integrations such as MCP. + + Args: + since: Fetch items published after this time + + Returns: + List[ContentItem]: All fetched items + """ + async with httpx.AsyncClient(timeout=30.0) as client: + tasks = [] + + # GitHub sources + if self.config.sources.github: + github_scraper = GitHubScraper(self.config.sources.github, client) + tasks.append(self._fetch_with_progress("GitHub", github_scraper, since)) + + # Hacker News + if self.config.sources.hackernews.enabled: + hn_scraper = HackerNewsScraper(self.config.sources.hackernews, client) + tasks.append(self._fetch_with_progress("Hacker News", hn_scraper, since)) + + # RSS feeds + if self.config.sources.rss: + from .extractors import ExtractorRegistry + rss_scraper = RSSScraper( + self.config.sources.rss, + client, + ExtractorRegistry(self.config.extractors), + ) + tasks.append(self._fetch_with_progress("RSS Feeds", rss_scraper, since)) + + # Reddit + if self.config.sources.reddit.enabled: + reddit_scraper = RedditScraper(self.config.sources.reddit, client) + tasks.append(self._fetch_with_progress("Reddit", reddit_scraper, since)) + + # Telegram + if self.config.sources.telegram.enabled: + telegram_scraper = TelegramScraper(self.config.sources.telegram, client) + tasks.append(self._fetch_with_progress("Telegram", telegram_scraper, since)) + + # Twitter (Apify or Playwright mode) + if self.config.sources.twitter and self.config.sources.twitter.enabled: + tw_cfg = self.config.sources.twitter + if tw_cfg.mode == "playwright": + twitter_scraper = TwitterPlaywrightScraper(tw_cfg) + else: + twitter_scraper = TwitterScraper(tw_cfg, client) + tasks.append(self._fetch_with_progress("Twitter", twitter_scraper, since)) + + # OpenBB (financial news / filings via the OpenBB Platform SDK) + if self.config.sources.openbb and self.config.sources.openbb.enabled: + openbb_scraper = OpenBBScraper(self.config.sources.openbb, client) + tasks.append(self._fetch_with_progress("OpenBB", openbb_scraper, since)) + + # OSS Insight trending repos + if self.config.sources.ossinsight and self.config.sources.ossinsight.enabled: + oss_scraper = OSSInsightScraper(self.config.sources.ossinsight, client) + tasks.append(self._fetch_with_progress("OSS Insight", oss_scraper, since)) + + # GDELT 2.0 DOC API (key-less global news) + if self.config.sources.gdelt and self.config.sources.gdelt.enabled: + gdelt_scraper = GDELTScraper(self.config.sources.gdelt, client) + tasks.append(self._fetch_with_progress("GDELT", gdelt_scraper, since)) + + # Google News RSS (key-less news search) + if self.config.sources.google_news and self.config.sources.google_news.enabled: + gn_scraper = GoogleNewsScraper(self.config.sources.google_news, client) + tasks.append(self._fetch_with_progress("Google News", gn_scraper, since)) + + # Fetch all concurrently + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Flatten results + all_items = [] + for result in results: + if isinstance(result, Exception): + self.console.print(f"[red]Error fetching source: {result}[/red]") + elif isinstance(result, list): + all_items.extend(result) + + return all_items + + async def _fetch_with_progress(self, name: str, scraper, since: datetime) -> List[ContentItem]: + """Fetch from a scraper with progress indication. + + Args: + name: Source name for display + scraper: Scraper instance + since: Fetch items after this time + + Returns: + List[ContentItem]: Fetched items + """ + self.console.print(f"🔍 Fetching from {name}...") + items = await scraper.fetch(since) + self.console.print(f" Found {len(items)} items from {name}") + + # Show per-sub-source breakdown when there are multiple sub-sources + sub_counts: Dict[str, int] = defaultdict(int) + for item in items: + sub_counts[self._sub_source_label(item)] += 1 + if len(sub_counts) > 1: + for sub, count in sorted(sub_counts.items()): + self.console.print(f" • {sub}: {count}") + + return items + + @staticmethod + def _sub_source_label(item: ContentItem) -> str: + """Return a human-readable sub-source label for an item.""" + meta = item.metadata + if meta.get("subreddit"): + return f"r/{meta['subreddit']}" + if meta.get("feed_name"): + return meta["feed_name"] + if meta.get("channel"): + return f"@{meta['channel']}" + if meta.get("period") and meta.get("repo"): + return f"ossinsight:{meta.get('primary_language', 'all')}" + if meta.get("repo"): + return meta["repo"] + if meta.get("watchlist"): + return meta["watchlist"] + if meta.get("source_name"): + return meta["source_name"] + if meta.get("gn_query"): + return f"google_news:{meta['gn_query']}" + if meta.get("domain"): + return meta["domain"] + return item.author or "unknown" + + def merge_cross_source_duplicates(self, items: List[ContentItem]) -> List[ContentItem]: + """Merge items that point to the same URL from different sources. + + This is a stable stage helper for integrations such as MCP. + + Keeps the item with the richest content and combines metadata. + + Args: + items: Items to deduplicate + + Returns: + List[ContentItem]: Deduplicated items + """ + def normalize_url(url: str) -> str: + parsed = urlparse(str(url)) + # Strip www prefix, trailing slashes, and fragments + host = parsed.hostname or "" + if host.startswith("www."): + host = host[4:] + path = parsed.path.rstrip("/") + return f"{host}{path}" + + # Group by normalized URL + url_groups: Dict[str, List[ContentItem]] = {} + for item in items: + key = normalize_url(str(item.url)) + url_groups.setdefault(key, []).append(item) + + merged = [] + for key, group in url_groups.items(): + if len(group) == 1: + merged.append(group[0]) + continue + + # Pick the item with the richest content as primary + primary = max(group, key=lambda x: len(x.content or "")) + + # Merge metadata and source info from other items + all_sources = set() + for item in group: + all_sources.add(item.source_type.value) + # Merge metadata (engagement, discussion, etc.) + for mk, mv in item.metadata.items(): + if mk not in primary.metadata or not primary.metadata[mk]: + primary.metadata[mk] = mv + + # Append content (e.g., comments from another source) + if item is not primary and item.content: + if primary.content and item.content not in primary.content: + primary.content = (primary.content or "") + f"\n\n--- From {item.source_type.value} ---\n" + item.content + + primary.metadata["merged_sources"] = list(all_sources) + merged.append(primary) + + return merged + + async def merge_topic_duplicates(self, items: List[ContentItem]) -> List[ContentItem]: + """Merge items covering the same topic using AI semantic deduplication. + + This is a stable stage helper for integrations such as MCP. + + Sends all item titles, tags, and summaries to AI in a single call. + Items must already be sorted by ai_score descending so that the first + item in each duplicate group is always the highest-scored one. + Content (comments) from duplicate items is merged into the primary. + + Falls back to returning items unchanged if the AI call fails. + """ + if len(items) <= 1: + return items + + from .ai.prompts import TOPIC_DEDUP_SYSTEM, TOPIC_DEDUP_USER + from .ai.utils import parse_json_response + + # Build the item list for the prompt + lines = [] + for i, item in enumerate(items): + tags = ", ".join(item.ai_tags) if item.ai_tags else "—" + summary = item.ai_summary or "—" + lines.append(f"[{i}] {item.title}\n Tags: {tags}\n Summary: {summary}") + items_text = "\n\n".join(lines) + + try: + ai_client = create_ai_client(self.config.ai) + response = await ai_client.complete( + system=TOPIC_DEDUP_SYSTEM, + user=TOPIC_DEDUP_USER.format(items=items_text), + ) + result = parse_json_response(response) + if result is None: + self.console.print("[yellow] dedup: could not parse AI response, skipping[/yellow]") + return items + + duplicate_groups = result.get("duplicates", []) + except Exception as e: + self.console.print(f"[yellow] dedup: AI call failed ({e}), skipping[/yellow]") + return items + + if not duplicate_groups: + return items + + # Build a set of indices to drop (all non-primary duplicates) + drop_indices: set[int] = set() + for group in duplicate_groups: + if not isinstance(group, list) or len(group) < 2: + continue + primary_idx = group[0] + if primary_idx < 0 or primary_idx >= len(items): + continue + primary = items[primary_idx] + for dup_idx in group[1:]: + if not isinstance(dup_idx, int) or dup_idx < 0 or dup_idx >= len(items): + continue + if dup_idx == primary_idx: + continue + dup = items[dup_idx] + # Merge comments/content from the duplicate into the primary + if dup.content: + if not primary.content or dup.content not in primary.content: + label = dup.source_type.value + primary.content = (primary.content or "") + f"\n\n--- From {label} ---\n{dup.content}" + self.console.print( + f" [dim]dedup: keep [{primary_idx}] {primary.title}[/dim]\n" + f" [dim] drop [{dup_idx}] {dup.title}[/dim]" + ) + drop_indices.add(dup_idx) + + return [item for i, item in enumerate(items) if i not in drop_indices] + + def apply_balanced_digest( + self, + items: List[ContentItem], + *, + log: bool = True, + ) -> BalancedDigestResult: + """Apply configured category quotas and the final item cap. + + Categories are read from ``item.metadata["category"]``. If a category + appears in more than one configured group, the first group in config + order wins. + """ + filtering = self.config.filtering + groups = filtering.category_groups + max_items = filtering.max_items + + if not groups and max_items is None: + return BalancedDigestResult(items=items) + + sorted_items = sorted( + items, + key=lambda item: item.ai_score or 0, + reverse=True, + ) + + category_to_group: Dict[str, str] = {} + duplicate_categories: List[str] = [] + for group_key, group in groups.items(): + for category in group.categories: + if category in category_to_group: + if category_to_group[category] != group_key: + duplicate_categories.append(category) + continue + category_to_group[category] = group_key + + if log: + for category in sorted(set(duplicate_categories)): + first_group = category_to_group[category] + self.console.print( + f"[yellow]Warning: category '{category}' is configured in multiple " + f"groups; using '{first_group}'.[/yellow]" + ) + + selected: List[tuple[ContentItem, str]] = [] + group_counts: Dict[str, int] = defaultdict(int) + default_group = filtering.default_group + + for item in sorted_items: + category = item.metadata.get("category") + group_key = ( + category_to_group.get(category, default_group) + if isinstance(category, str) + else default_group + ) + + if group_key in groups: + limit = groups[group_key].limit + else: + limit = filtering.default_group_limit + + if limit is not None and group_counts[group_key] >= limit: + continue + + selected.append((item, group_key)) + group_counts[group_key] += 1 + + if max_items is not None: + selected = selected[:max_items] + + final_counts: Dict[str, int] = defaultdict(int) + for _, group_key in selected: + final_counts[group_key] += 1 + + group_limits: Dict[str, Optional[int]] = { + group_key: group.limit for group_key, group in groups.items() + } + group_limits.setdefault(default_group, filtering.default_group_limit) + + if log: + self.console.print( + f"⚖️ Balanced digest selected {len(selected)}/{len(items)} items" + ) + for group_key, group in groups.items(): + label = group.name or group_key + self.console.print( + f" • {label}: {final_counts.get(group_key, 0)}/{group.limit}" + ) + if ( + final_counts.get(default_group, 0) + or filtering.default_group_limit is not None + ): + limit_label = ( + str(filtering.default_group_limit) + if filtering.default_group_limit is not None + else "unlimited" + ) + self.console.print( + f" • {default_group}: " + f"{final_counts.get(default_group, 0)}/{limit_label}" + ) + self.console.print("") + + return BalancedDigestResult( + items=[item for item, _ in selected], + enabled=True, + group_counts=dict(final_counts), + group_limits=group_limits, + duplicate_categories=sorted(set(duplicate_categories)), + ) + + async def _expand_twitter_discussion(self, items: List[ContentItem]) -> None: + """Second-stage: fetch reply text for important Twitter items and re-analyze. + + Only runs when sources.twitter.fetch_reply_text is True. + Bounded by max_tweets_to_expand to control cost. + """ + tw_cfg = self.config.sources.twitter + if not tw_cfg or not tw_cfg.enabled or not tw_cfg.fetch_reply_text: + return + + from .models import SourceType + + twitter_items = [ + item for item in items + if item.source_type == SourceType.TWITTER + ][:tw_cfg.max_tweets_to_expand] + + if not twitter_items: + return + + self.console.print( + f"💬 Fetching reply text for {len(twitter_items)} Twitter items..." + ) + + async with httpx.AsyncClient(timeout=30.0) as client: + if tw_cfg.mode == "playwright": + self.console.print( + " [yellow]Reply expansion not yet supported in Playwright mode.[/yellow]" + ) + return + scraper = TwitterScraper(tw_cfg, client) + expanded = [] + for item in twitter_items: + try: + reply_lines = await scraper.fetch_replies_for_item(item) + if TwitterScraper.append_discussion_content(item, reply_lines): + expanded.append(item) + self.console.print( + f" 💬 {len(reply_lines)} replies added to: {item.title[:60]}" + ) + except Exception as exc: + self.console.print( + f" [yellow]⚠️ Reply fetch failed for {item.id}: {exc}[/yellow]" + ) + + if not expanded: + return + + self.console.print( + f" Re-analyzing {len(expanded)} Twitter items with reply context...\n" + ) + ai_client = create_ai_client(self.config.ai) + analyzer = ContentAnalyzer(ai_client) + await analyzer.analyze_batch(expanded) + + async def _enrich_important_items(self, items: List[ContentItem]) -> None: + """Enrich items with background knowledge (2nd AI pass). + + For each item that passed the score threshold, call AI to generate + background knowledge based on the item's actual content. + + Args: + items: Important items to enrich (modified in-place) + """ + if not items: + return + + self.console.print("📚 Enriching with background knowledge...") + ai_client = create_ai_client(self.config.ai) + enricher = ContentEnricher(ai_client) + await enricher.enrich_batch(items) + self.console.print(f" Enriched {len(items)} items\n") + + async def _analyze_content(self, items: List[ContentItem]) -> List[ContentItem]: + """Analyze content items with AI. + + Args: + items: Items to analyze + + Returns: + List[ContentItem]: Analyzed items + """ + self.console.print("🤖 Analyzing content with AI...") + + ai_client = create_ai_client(self.config.ai) + analyzer = ContentAnalyzer(ai_client) + + return await analyzer.analyze_batch(items) + + async def _generate_summary( + self, + items: List[ContentItem], + date: str, + total_fetched: int, + language: str = "en", + ) -> str: + """Generate daily summary. + + Args: + items: Important items to include (already enriched with background/related) + date: Date string + total_fetched: Total items fetched + language: Output language ("en" or "zh") + + Returns: + str: Markdown summary + """ + self.console.print("📝 Generating daily summary...") + + summarizer = DailySummarizer() + + return await summarizer.generate_summary(items, date, total_fetched, language=language) diff --git a/src/scrapers/__init__.py b/src/scrapers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/scrapers/base.py b/src/scrapers/base.py new file mode 100644 index 0000000..bdc85e0 --- /dev/null +++ b/src/scrapers/base.py @@ -0,0 +1,47 @@ +"""Base scraper interface.""" + +from abc import ABC, abstractmethod +from datetime import datetime +from typing import List +import httpx + +from ..models import ContentItem + + +class BaseScraper(ABC): + """Abstract base class for all scrapers.""" + + def __init__(self, config: dict, http_client: httpx.AsyncClient): + """Initialize scraper. + + Args: + config: Scraper-specific configuration + http_client: Shared async HTTP client + """ + self.config = config + self.client = http_client + + @abstractmethod + async def fetch(self, since: datetime) -> List[ContentItem]: + """Fetch content items published since the given time. + + Args: + since: Only fetch items published after this time + + Returns: + List[ContentItem]: Fetched content items + """ + pass + + def _generate_id(self, source_type: str, subtype: str, native_id: str) -> str: + """Generate unique content item ID. + + Args: + source_type: Source type (github, hackernews, etc.) + subtype: Content subtype (event, release, story, etc.) + native_id: Native ID from the source platform + + Returns: + str: Unique ID in format {source}:{subtype}:{native_id} + """ + return f"{source_type}:{subtype}:{native_id}" diff --git a/src/scrapers/gdelt.py b/src/scrapers/gdelt.py new file mode 100644 index 0000000..e9f372c --- /dev/null +++ b/src/scrapers/gdelt.py @@ -0,0 +1,184 @@ +"""GDELT 2.0 DOC API scraper. + +Pulls recent global news articles from the key-less GDELT 2.0 DOC API +(https://api.gdeltproject.org/api/v2/doc/doc) and maps them into +ContentItem instances so the rest of the Horizon pipeline (deduplication, +AI scoring, enrichment, summarization) treats them the same way as RSS or +Hacker News items. + +Design notes: + +* No API key is required; GDELT is fully open. The DOC API caps results at + 250 records per request, so `max_records` is kept modest by default. +* GDELT expresses source-language and source-country filters as query + operators (``sourcelang:`` / ``sourcecountry:``) rather than separate + parameters, so they are appended to the query string. +* GDELT can return non-JSON or empty bodies on transient errors, so the + ``.json()`` call is guarded and an empty/missing ``articles`` key yields + an empty list rather than raising. +* A single malformed article is skipped, not allowed to abort the batch. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, List, Optional + +import httpx + +from .base import BaseScraper +from ..models import ContentItem, GDELTConfig, SourceType + +logger = logging.getLogger(__name__) + + +class GDELTScraper(BaseScraper): + """Scraper backed by the GDELT 2.0 DOC API.""" + + SOURCE_TYPE = SourceType.GDELT + BASE_URL = "https://api.gdeltproject.org/api/v2/doc/doc" + + def __init__(self, config: GDELTConfig, http_client: httpx.AsyncClient): + """Initialize the scraper. + + Args: + config: GDELT source configuration. + http_client: Shared async HTTP client. + """ + super().__init__({"gdelt": config}, http_client) + self.gdelt_config = config + + async def fetch(self, since: datetime) -> List[ContentItem]: + """Fetch articles from the GDELT DOC API. + + Args: + since: Only fetch items published after this time (used to derive + the GDELT ``startdatetime`` unless ``timespan`` is set). + + Returns: + List[ContentItem]: Fetched content items. + """ + if not self.gdelt_config.enabled: + return [] + + query = (self.gdelt_config.query or "").strip() + if not query: + return [] + + # GDELT expresses language/country as query operators. + if self.gdelt_config.language: + query = f"{query} sourcelang:{self.gdelt_config.language}" + if self.gdelt_config.country: + query = f"{query} sourcecountry:{self.gdelt_config.country}" + + params: dict[str, Any] = { + "query": query, + "mode": self.gdelt_config.mode, + "format": "json", + "maxrecords": self.gdelt_config.max_records, + "sort": "datedesc", + } + + # timespan takes precedence over an explicit start/end window. + if self.gdelt_config.timespan: + params["timespan"] = self.gdelt_config.timespan + else: + since_utc = self._ensure_utc(since) + now_utc = datetime.now(timezone.utc) + params["startdatetime"] = since_utc.strftime("%Y%m%d%H%M%S") + params["enddatetime"] = now_utc.strftime("%Y%m%d%H%M%S") + + try: + response = await self.client.get( + self.BASE_URL, params=params, follow_redirects=True + ) + response.raise_for_status() + + try: + payload = response.json() + except Exception as exc: + logger.warning("GDELT returned a non-JSON body: %s", exc) + return [] + + if not isinstance(payload, dict): + return [] + + articles = payload.get("articles") + if not articles: + return [] + + items: List[ContentItem] = [] + for raw in articles: + item = self._raw_to_item(raw) + if item is not None: + items.append(item) + return items + + except httpx.HTTPError as exc: + logger.warning("Error fetching GDELT articles: %s", exc) + return [] + except Exception as exc: + logger.warning("Error parsing GDELT response: %s", exc) + return [] + + def _raw_to_item(self, raw: Any) -> Optional[ContentItem]: + """Map one GDELT article record into a ContentItem. + + Returns None when the record has no URL/title or an unparseable + ``seendate`` (published_at is required), so a single bad article is + skipped rather than aborting the batch. + """ + if not isinstance(raw, dict): + return None + + url = (raw.get("url") or "").strip() + title = (raw.get("title") or "").strip() + if not url or not title: + return None + + seendate = (raw.get("seendate") or "").strip() + published = self._parse_seendate(seendate) + if published is None: + return None + + native_id = f"{seendate}::{url}" + meta = { + "domain": raw.get("domain"), + "sourcecountry": raw.get("sourcecountry"), + "language": raw.get("language"), + "query": self.gdelt_config.query, + "category": self.gdelt_config.category, + } + + try: + return ContentItem( + id=self._generate_id("gdelt", "article", native_id), + source_type=self.SOURCE_TYPE, + title=title, + url=url, + content=None, + author=raw.get("domain"), + published_at=published, + metadata={k: v for k, v in meta.items() if v is not None}, + ) + except Exception as exc: + logger.warning("Skipping invalid GDELT article %s: %s", url, exc) + return None + + @staticmethod + def _parse_seendate(value: str) -> Optional[datetime]: + """Parse a GDELT ``seendate`` ("YYYYMMDDTHHMMSSZ") into aware UTC.""" + if not value: + return None + try: + dt = datetime.strptime(value, "%Y%m%dT%H%M%SZ") + except (ValueError, TypeError): + return None + return dt.replace(tzinfo=timezone.utc) + + @staticmethod + def _ensure_utc(moment: datetime) -> datetime: + if moment.tzinfo is None: + return moment.replace(tzinfo=timezone.utc) + return moment.astimezone(timezone.utc) diff --git a/src/scrapers/github.py b/src/scrapers/github.py new file mode 100644 index 0000000..922bf09 --- /dev/null +++ b/src/scrapers/github.py @@ -0,0 +1,222 @@ +"""GitHub scraper implementation.""" + +import logging +import os +from datetime import datetime +from typing import List, Optional +import httpx + +from .base import BaseScraper +from ..models import ContentItem, SourceType, GitHubSourceConfig + +logger = logging.getLogger(__name__) + + +class GitHubScraper(BaseScraper): + """Scraper for GitHub events and releases.""" + + def __init__(self, sources: List[GitHubSourceConfig], http_client: httpx.AsyncClient): + """Initialize GitHub scraper. + + Args: + sources: List of GitHub source configurations + http_client: Shared async HTTP client + """ + super().__init__({"sources": sources}, http_client) + self.token = os.getenv("GITHUB_TOKEN") + self.base_url = "https://api.github.com" + + def _get_headers(self) -> dict: + """Get request headers with optional authentication. + + Returns: + dict: HTTP headers + """ + headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "Horizon-Aggregator" + } + if self.token: + headers["Authorization"] = f"token {self.token}" + return headers + + async def fetch(self, since: datetime) -> List[ContentItem]: + """Fetch GitHub content items. + + Args: + since: Only fetch items published after this time + + Returns: + List[ContentItem]: Fetched content items + """ + items = [] + sources = self.config["sources"] + + for source in sources: + if not source.enabled: + continue + + if source.type == "user_events" and source.username: + user_items = await self._fetch_user_events(source, since) + items.extend(user_items) + elif source.type == "repo_releases" and source.owner and source.repo: + release_items = await self._fetch_repo_releases(source, since) + items.extend(release_items) + + return items + + async def _fetch_user_events( + self, + source: GitHubSourceConfig, + since: datetime, + ) -> List[ContentItem]: + """Fetch public events for a user. + + Args: + source: GitHub source configuration + since: Only fetch events after this time + + Returns: + List[ContentItem]: Event content items + """ + url = f"{self.base_url}/users/{source.username}/events/public" + items = [] + + try: + response = await self.client.get(url, headers=self._get_headers(), follow_redirects=True) + response.raise_for_status() + events = response.json() + + for event in events: + created_at = datetime.fromisoformat( + event["created_at"].replace("Z", "+00:00") + ) + + if created_at < since: + continue + + # Filter interesting event types + event_type = event["type"] + if event_type not in [ + "PushEvent", "CreateEvent", "ReleaseEvent", + "PublicEvent", "WatchEvent" + ]: + continue + + item = self._parse_event(event, source) + if item: + items.append(item) + + except httpx.HTTPError as e: + logger.warning("Error fetching GitHub events for %s: %s", source.username, e) + + return items + + def _parse_event(self, event: dict, source: GitHubSourceConfig) -> Optional[ContentItem]: + """Parse GitHub event into ContentItem. + + Args: + event: GitHub event data + username: GitHub username + + Returns: + Optional[ContentItem]: Parsed content item or None + """ + event_type = event["type"] + event_id = event["id"] + created_at = datetime.fromisoformat(event["created_at"].replace("Z", "+00:00")) + username = source.username + + repo_name = event["repo"]["name"] + repo_url = f"https://github.com/{repo_name}" + + # Generate title and content based on event type + if event_type == "PushEvent": + commits = event["payload"].get("commits", []) + title = f"{username} pushed {len(commits)} commit(s) to {repo_name}" + content = "\n".join([c.get("message", "") for c in commits[:3]]) + elif event_type == "CreateEvent": + ref_type = event["payload"].get("ref_type", "repository") + title = f"{username} created {ref_type} in {repo_name}" + content = event["payload"].get("description", "") + elif event_type == "ReleaseEvent": + release = event["payload"].get("release", {}) + title = f"{username} released {release.get('tag_name', '')} in {repo_name}" + content = release.get("body", "") + repo_url = release.get("html_url", repo_url) + elif event_type == "PublicEvent": + title = f"{username} made {repo_name} public" + content = "" + elif event_type == "WatchEvent": + title = f"{username} starred {repo_name}" + content = "" + else: + return None + + return ContentItem( + id=self._generate_id("github", "event", event_id), + source_type=SourceType.GITHUB, + title=title, + url=repo_url, + content=content, + author=username, + published_at=created_at, + metadata={ + "event_type": event_type, + "repo": repo_name, + "category": source.category, + } + ) + + async def _fetch_repo_releases( + self, + source: GitHubSourceConfig, + since: datetime, + ) -> List[ContentItem]: + """Fetch releases for a repository. + + Args: + source: GitHub source configuration + since: Only fetch releases after this time + + Returns: + List[ContentItem]: Release content items + """ + owner, repo = source.owner, source.repo + url = f"{self.base_url}/repos/{owner}/{repo}/releases" + items = [] + + try: + response = await self.client.get(url, headers=self._get_headers(), follow_redirects=True) + response.raise_for_status() + releases = response.json() + + for release in releases: + published_at = datetime.fromisoformat( + release["published_at"].replace("Z", "+00:00") + ) + + if published_at < since: + continue + + item = ContentItem( + id=self._generate_id("github", "release", str(release["id"])), + source_type=SourceType.GITHUB, + title=f"{owner}/{repo} released {release['tag_name']}", + url=release["html_url"], + content=release.get("body", ""), + author=release["author"]["login"], + published_at=published_at, + metadata={ + "repo": f"{owner}/{repo}", + "tag": release["tag_name"], + "prerelease": release.get("prerelease", False), + "category": source.category, + } + ) + items.append(item) + + except httpx.HTTPError as e: + logger.warning("Error fetching releases for %s/%s: %s", owner, repo, e) + + return items diff --git a/src/scrapers/google_news.py b/src/scrapers/google_news.py new file mode 100644 index 0000000..60c9d9f --- /dev/null +++ b/src/scrapers/google_news.py @@ -0,0 +1,223 @@ +"""Google News RSS search scraper. + +Pulls recent news articles from Google News' key-less RSS search endpoint +(https://news.google.com/rss/search) for a configured query and maps each +feed entry into a ContentItem so the rest of the Horizon pipeline +(deduplication, AI scoring, enrichment, summarization) treats them the same +way as RSS, Hacker News, or GDELT items. + +Design notes: + +* No API key is required; Google News exposes an open RSS search endpoint. +* The desired time window is expressed as a Google News query *operator* + rather than a request parameter. ``since`` is converted to an integer + number of hours; for windows up to 100 hours we use ``when:h`` + (Google News supports ``when:Nh`` / ``when:Nd``), and for longer windows + we fall back to ``after:YYYY-MM-DD`` using the ``since`` date. This keeps + the mapping deterministic and lets Google bound the result set. +* Localization is expressed through the ``hl`` (language), ``gl`` (country) + and ``ceid`` params; ``ceid`` defaults to ``"{country}:{language}"`` when + not configured. +* Google News headlines are usually formatted "Headline - Publisher" and the + publisher is also exposed via ``entry.source.title``; it is captured into + the ``source_name`` metadata key defensively. +* A single malformed entry is skipped, not allowed to abort the batch. +""" + +from __future__ import annotations + +import calendar +import hashlib +import logging +import math +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Any, List, Optional + +import feedparser +import httpx + +from .base import BaseScraper +from ..models import ContentItem, GoogleNewsConfig, SourceType + +logger = logging.getLogger(__name__) + + +class GoogleNewsScraper(BaseScraper): + """Scraper backed by the Google News RSS search endpoint.""" + + SOURCE_TYPE = SourceType.GOOGLE_NEWS + BASE_URL = "https://news.google.com/rss/search" + + def __init__(self, config: GoogleNewsConfig, http_client: httpx.AsyncClient): + """Initialize the scraper. + + Args: + config: Google News source configuration. + http_client: Shared async HTTP client. + """ + super().__init__({"google_news": config}, http_client) + self.gn_config = config + + async def fetch(self, since: datetime) -> List[ContentItem]: + """Fetch articles from the Google News RSS search endpoint. + + Args: + since: Only fetch items published after this time (used to derive + the Google News ``when:``/``after:`` query operator). + + Returns: + List[ContentItem]: Fetched content items. + """ + if not self.gn_config.enabled: + return [] + + base_query = (self.gn_config.query or "").strip() + if not base_query: + return [] + + query = f"{base_query} {self._time_operator(since)}" + + ceid = self.gn_config.ceid or f"{self.gn_config.country}:{self.gn_config.language}" + params: dict[str, Any] = { + "q": query, + "hl": self.gn_config.language, + "gl": self.gn_config.country, + "ceid": ceid, + } + + try: + response = await self.client.get( + self.BASE_URL, params=params, follow_redirects=True + ) + response.raise_for_status() + + feed = feedparser.parse(response.text) + + items: List[ContentItem] = [] + for entry in feed.entries: + if len(items) >= self.gn_config.max_results: + break + item = self._entry_to_item(entry) + if item is not None: + items.append(item) + return items + + except httpx.HTTPError as exc: + logger.warning("Error fetching Google News feed: %s", exc) + return [] + except Exception as exc: + logger.warning("Error parsing Google News feed: %s", exc) + return [] + + def _time_operator(self, since: datetime) -> str: + """Build the Google News time operator from ``since``. + + Computes the number of whole hours between ``since`` and now (min 1). + For windows up to 100 hours we use ``when:h``; for longer + windows Google News' relative operator is unreliable, so we fall back + to ``after:YYYY-MM-DD`` using the ``since`` date. + """ + since_utc = self._ensure_utc(since) + now_utc = datetime.now(timezone.utc) + seconds = (now_utc - since_utc).total_seconds() + hours = max(1, math.ceil(seconds / 3600)) + if hours <= 100: + return f"when:{hours}h" + return f"after:{since_utc.strftime('%Y-%m-%d')}" + + def _entry_to_item(self, entry: Any) -> Optional[ContentItem]: + """Map one Google News RSS entry into a ContentItem. + + Returns None when the entry has no title/link or an unparseable + published date (published_at is required), so a single bad entry is + skipped rather than aborting the batch. + """ + try: + title = (entry.get("title") or "").strip() + if not title: + return None + + link = (entry.get("link") or "").strip() + if not link: + return None + + published = self._parse_date(entry) + if published is None: + return None + + source_name = self._extract_source_name(entry) + + entry_id = entry.get("id") or link + entry_hash = hashlib.sha256(str(entry_id).encode("utf-8")).hexdigest()[:16] + + meta = { + "gn_query": self.gn_config.query, + "source_name": source_name, + "category": self.gn_config.category, + } + + return ContentItem( + id=self._generate_id("google_news", "article", entry_hash), + source_type=self.SOURCE_TYPE, + title=title, + url=link, + content=self._extract_content(entry), + author=source_name, + published_at=published, + metadata={k: v for k, v in meta.items() if v is not None}, + ) + except Exception as exc: + logger.warning("Skipping invalid Google News entry: %s", exc) + return None + + @staticmethod + def _extract_source_name(entry: Any) -> Optional[str]: + """Extract the publisher name from a Google News entry, guarding misses.""" + source = entry.get("source") + if isinstance(source, dict): + name = source.get("title") + if name: + return str(name).strip() + # feedparser may expose source as an attribute-bearing object. + title = getattr(source, "title", None) + if title: + return str(title).strip() + return None + + @staticmethod + def _parse_date(entry: Any) -> Optional[datetime]: + """Parse the publication date of an entry into aware UTC.""" + for field in ["published", "updated", "created"]: + if field in entry: + try: + parsed = entry.get(f"{field}_parsed") + if parsed: + return datetime.fromtimestamp( + calendar.timegm(parsed), tz=timezone.utc + ) + return parsedate_to_datetime(entry[field]) + except Exception: + continue + return None + + @staticmethod + def _extract_content(entry: Any) -> Optional[str]: + """Extract text content from a Google News entry, if any.""" + if "summary" in entry: + return entry.summary + if "description" in entry: + return entry.description + content = entry.get("content") + if content: + try: + return content[0].get("value", "") + except Exception: + return None + return None + + @staticmethod + def _ensure_utc(moment: datetime) -> datetime: + if moment.tzinfo is None: + return moment.replace(tzinfo=timezone.utc) + return moment.astimezone(timezone.utc) diff --git a/src/scrapers/hackernews.py b/src/scrapers/hackernews.py new file mode 100644 index 0000000..946d256 --- /dev/null +++ b/src/scrapers/hackernews.py @@ -0,0 +1,143 @@ +"""Hacker News scraper implementation.""" + +import logging +import re +from datetime import datetime, timezone +from typing import List, Optional +import asyncio +import httpx + +from .base import BaseScraper +from ..models import ContentItem, SourceType, HackerNewsConfig + +logger = logging.getLogger(__name__) + +# Max top-level comments to fetch per story +TOP_COMMENTS_LIMIT = 5 + + +class HackerNewsScraper(BaseScraper): + """Scraper for Hacker News stories with top comments.""" + + def __init__(self, config: HackerNewsConfig, http_client: httpx.AsyncClient): + super().__init__(config.model_dump(), http_client) + self.base_url = "https://hacker-news.firebaseio.com/v0" + + async def fetch(self, since: datetime) -> List[ContentItem]: + if not self.config.get("enabled", True): + return [] + + try: + response = await self.client.get(f"{self.base_url}/topstories.json") + response.raise_for_status() + story_ids = response.json() + + fetch_count = self.config.get("fetch_top_stories", 30) + story_ids = story_ids[:fetch_count] + + # Fetch story details concurrently + tasks = [self._fetch_story(story_id) for story_id in story_ids] + stories = await asyncio.gather(*tasks, return_exceptions=True) + + # Filter and process stories, then fetch comments + items = [] + min_score = self.config.get("min_score", 100) + + comment_tasks = [] + valid_stories = [] + + for story in stories: + if isinstance(story, Exception) or story is None: + continue + if story.get("score", 0) < min_score: + continue + published_at = datetime.fromtimestamp(story["time"], tz=timezone.utc) + if published_at < since: + continue + valid_stories.append(story) + # Queue comment fetching + comment_ids = story.get("kids", [])[:TOP_COMMENTS_LIMIT] + comment_tasks.append(self._fetch_comments(comment_ids)) + + # Fetch all comments concurrently + all_comments = await asyncio.gather(*comment_tasks, return_exceptions=True) + + for story, comments in zip(valid_stories, all_comments): + if isinstance(comments, Exception): + comments = [] + item = self._parse_story(story, comments) + if item: + items.append(item) + + return items + + except httpx.HTTPError as e: + logger.warning("Error fetching Hacker News stories: %s", e) + return [] + + async def _fetch_story(self, story_id: int) -> Optional[dict]: + try: + response = await self.client.get(f"{self.base_url}/item/{story_id}.json") + response.raise_for_status() + return response.json() + except httpx.HTTPError: + return None + + async def _fetch_comments(self, comment_ids: List[int]) -> List[dict]: + """Fetch multiple comments concurrently.""" + if not comment_ids: + return [] + + tasks = [self._fetch_story(cid) for cid in comment_ids] + results = await asyncio.gather(*tasks, return_exceptions=True) + + comments = [] + for r in results: + if isinstance(r, dict) and r.get("text") and not r.get("deleted") and not r.get("dead"): + comments.append(r) + return comments + + def _parse_story(self, story: dict, comments: List[dict]) -> ContentItem: + story_id = story["id"] + title = story.get("title", "") + url = story.get("url", f"https://news.ycombinator.com/item?id={story_id}") + author = story.get("by", "unknown") + published_at = datetime.fromtimestamp(story["time"], tz=timezone.utc) + + # Build content: original text + top comments + parts = [] + if story.get("text"): + parts.append(story["text"]) + + if comments: + parts.append("\n--- Top Comments ---") + for c in comments: + commenter = c.get("by", "anon") + text = c.get("text", "") + # Strip HTML tags roughly + text = re.sub(r'<[^>]+>', ' ', text).strip() + # Truncate very long comments + if len(text) > 500: + text = text[:497] + "..." + parts.append(f"[{commenter}]: {text}") + + content = "\n\n".join(parts) + hn_discussion_url = f"https://news.ycombinator.com/item?id={story_id}" + + return ContentItem( + id=self._generate_id("hackernews", "story", str(story_id)), + source_type=SourceType.HACKERNEWS, + title=title, + url=url, + content=content, + author=author, + published_at=published_at, + metadata={ + "score": story.get("score", 0), + "descendants": story.get("descendants", 0), + "type": story.get("type", "story"), + "discussion_url": hn_discussion_url, + "comment_count": len(comments), + "category": self.config.get("category"), + } + ) diff --git a/src/scrapers/openbb.py b/src/scrapers/openbb.py new file mode 100644 index 0000000..3d0b5f5 --- /dev/null +++ b/src/scrapers/openbb.py @@ -0,0 +1,237 @@ +"""OpenBB Platform scraper. + +Pulls company news (and optionally filings) from the OpenBB SDK and maps +them into ContentItem instances so the rest of the Horizon pipeline +(deduplication, AI scoring, enrichment, summarization) treats them the +same way as RSS or Hacker News items. + +The `openbb` package is declared as an optional dependency in +pyproject.toml. If it is not installed the scraper logs a warning and +returns an empty list rather than crashing, so a user can enable the +OpenBB source without blocking the core pipeline. + +Design notes: + +* One ``news.company()`` call per watchlist. Grouping tickers by provider + keeps request counts low (the OpenBB multi-symbol form does the + fan-out internally for providers that support it). +* Filings fetches are optional and off by default; SEC filings are free + but noisy for non-investors. +* ``obb`` is synchronous, so we wrap calls in ``asyncio.to_thread`` to + keep the orchestrator's event loop responsive. +* Provider credentials (FMP/Benzinga/Polygon/Intrinio/...) are read by + the OpenBB SDK from its own settings/environment and are not passed + through Horizon. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from typing import Any, Iterable, List, Optional + +import httpx + +from .base import BaseScraper +from ..models import ContentItem, OpenBBConfig, OpenBBWatchlist, SourceType + +logger = logging.getLogger(__name__) + + +class OpenBBScraper(BaseScraper): + """Scraper backed by the OpenBB Platform Python SDK.""" + + SOURCE_TYPE = SourceType.OPENBB + + def __init__(self, config: OpenBBConfig, http_client: httpx.AsyncClient): + """Initialize the scraper. + + Args: + config: OpenBB source configuration. + http_client: Shared httpx client (unused here; kept for the + BaseScraper contract). + """ + super().__init__({"openbb": config}, http_client) + self.openbb_config = config + self._obb = self._try_import_obb() + + @staticmethod + def _try_import_obb() -> Optional[Any]: + """Try to import the OpenBB App, returning None if not installed. + + Importing ``openbb`` is expensive (loads every registered + extension), so we do it once at construction time. If the user + did not install the ``openbb`` optional extra we log a warning + and disable the scraper instead of raising. + """ + try: + from openbb import obb + return obb + except ImportError: + logger.warning( + "OpenBB source is enabled but the 'openbb' package is not " + "installed. Install it with: " + "uv pip install --only-binary=:all: openbb" + ) + return None + + async def fetch(self, since: datetime) -> List[ContentItem]: + """Fetch items from all enabled OpenBB watchlists. + + Args: + since: Only return items published strictly after this time. + + Returns: + Deduplicated list of content items across all watchlists. + """ + if not self._obb or not self.openbb_config.enabled: + return [] + + since_utc = self._ensure_utc(since) + seen_urls: set[str] = set() + items: List[ContentItem] = [] + + for watchlist in self.openbb_config.watchlists: + if not watchlist.enabled or not watchlist.symbols: + continue + try: + fetched = await self._fetch_watchlist(watchlist, since_utc) + except Exception as exc: + logger.warning( + "OpenBB watchlist '%s' failed: %s", + watchlist.name, + exc, + ) + continue + for item in fetched: + url_key = str(item.url) + if url_key in seen_urls: + continue + seen_urls.add(url_key) + items.append(item) + + return items + + async def _fetch_watchlist( + self, + watchlist: OpenBBWatchlist, + since_utc: datetime, + ) -> List[ContentItem]: + """Fetch news for one watchlist via ``obb.news.company()``.""" + symbols_param = ",".join(watchlist.symbols) + response = await asyncio.to_thread( + self._obb.news.company, + symbol=symbols_param, + limit=watchlist.fetch_limit, + provider=watchlist.provider, + ) + results = getattr(response, "results", None) or [] + items: List[ContentItem] = [] + for raw in results: + item = self._raw_to_item(raw, watchlist, since_utc) + if item is not None: + items.append(item) + return items + + def _raw_to_item( + self, + raw: Any, + watchlist: OpenBBWatchlist, + since_utc: datetime, + ) -> Optional[ContentItem]: + """Map one OpenBB news record into a ContentItem. + + Returns None when the record is too old, has no URL, or fails + validation. OpenBB returns Pydantic models, so we read attributes + directly and defensively. + """ + url = self._coerce_url(getattr(raw, "url", None)) + if not url: + return None + + published = self._coerce_datetime(getattr(raw, "date", None)) + if published is None or published <= since_utc: + return None + + title = (getattr(raw, "title", None) or "").strip() + if not title: + return None + + body = getattr(raw, "body", None) or getattr(raw, "excerpt", None) + author = getattr(raw, "author", None) + raw_symbols = getattr(raw, "symbols", None) or "" + symbols = self._parse_symbols(raw_symbols) or list(watchlist.symbols) + + native_id = self._derive_native_id(url, published) + meta = { + "watchlist": watchlist.name, + "provider": watchlist.provider, + "symbols": symbols, + "category": watchlist.category, + } + + return ContentItem( + id=self._generate_id("openbb", "news", native_id), + source_type=self.SOURCE_TYPE, + title=title, + url=url, + content=body, + author=author or (symbols[0] if symbols else None), + published_at=published, + metadata={k: v for k, v in meta.items() if v is not None}, + ) + + @staticmethod + def _ensure_utc(moment: datetime) -> datetime: + if moment.tzinfo is None: + return moment.replace(tzinfo=timezone.utc) + return moment.astimezone(timezone.utc) + + @staticmethod + def _coerce_datetime(value: Any) -> Optional[datetime]: + """Normalize whatever OpenBB returns for `date` into aware UTC.""" + if value is None: + return None + if isinstance(value, datetime): + dt = value + elif isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + @staticmethod + def _coerce_url(value: Any) -> Optional[str]: + if not value: + return None + text = str(value).strip() + return text or None + + @staticmethod + def _parse_symbols(raw_symbols: Any) -> List[str]: + if isinstance(raw_symbols, str): + parts: Iterable[str] = raw_symbols.split(",") + elif isinstance(raw_symbols, (list, tuple, set)): + parts = raw_symbols + else: + return [] + cleaned = [str(p).strip().upper() for p in parts if str(p).strip()] + seen: set[str] = set() + unique: List[str] = [] + for sym in cleaned: + if sym in seen: + continue + seen.add(sym) + unique.append(sym) + return unique + + @staticmethod + def _derive_native_id(url: str, published: datetime) -> str: + """Build a stable id from url + timestamp to survive URL mirrors.""" + return f"{published.strftime('%Y%m%dT%H%M%S')}::{url}" diff --git a/src/scrapers/ossinsight.py b/src/scrapers/ossinsight.py new file mode 100644 index 0000000..87d8715 --- /dev/null +++ b/src/scrapers/ossinsight.py @@ -0,0 +1,154 @@ +"""OSS Insight trending repos scraper. + +Fetches star-gain rankings from api.ossinsight.io and emits them as +ContentItems. An optional keyword filter narrows results to repos whose +description, repo name, or collection names match at least one configured +substring (case-insensitive). Without keywords, all trending repos in the +configured languages flow through. +""" + +from datetime import datetime, timezone +from typing import List, Optional + +import httpx + +from ..models import ContentItem, OSSInsightConfig, SourceType +from .base import BaseScraper + + +class OSSInsightScraper(BaseScraper): + """Scraper for OSS Insight trending repositories endpoint.""" + + BASE_URL = "https://api.ossinsight.io/v1/trends/repos" + + def __init__(self, config: OSSInsightConfig, http_client: httpx.AsyncClient): + """Initialize scraper. + + Args: + config: OSS Insight source configuration + http_client: Shared async HTTP client + """ + super().__init__(config, http_client) + self.cfg: OSSInsightConfig = config + self._keywords_lower = [kw.lower() for kw in self.cfg.keywords if kw] + + async def fetch(self, since: datetime) -> List[ContentItem]: + """Fetch trending repos for each configured language and apply filters.""" + if not self.cfg.enabled: + return [] + + items: List[ContentItem] = [] + seen_ids: set[str] = set() + + for lang in self.cfg.languages: + rows = await self._fetch_period(self.cfg.period, lang) + for row in rows: + item = self._row_to_item(row, lang) + if item is None: + continue + if item.id in seen_ids: + continue + if self.cfg.min_stars and self._stars_int(row) < self.cfg.min_stars: + continue + if self._keywords_lower and not self._matches_keywords(row): + continue + seen_ids.add(item.id) + items.append(item) + + items.sort(key=lambda x: x.metadata.get("stars_gained", 0), reverse=True) + return items[: self.cfg.max_items] + + async def _fetch_period(self, period: str, language: str) -> List[dict]: + """Call OSS Insight API for one (period, language) combo.""" + params = {"period": period, "language": language} + try: + response = await self.client.get( + self.BASE_URL, + params=params, + headers={"Accept": "application/json", "User-Agent": "Horizon/1.0"}, + timeout=20.0, + ) + response.raise_for_status() + except httpx.HTTPError: + return [] + + payload = response.json() + data = payload.get("data") or {} + rows = data.get("rows") or [] + return rows + + def _row_to_item(self, row: dict, language: str) -> Optional[ContentItem]: + """Convert a raw OSS Insight row into a ContentItem.""" + repo_name = row.get("repo_name") + repo_id = row.get("repo_id") + if not repo_name or not repo_id: + return None + + stars_gained = self._stars_int(row) + description = (row.get("description") or "").strip() + primary_language = row.get("primary_language") or language + + title = f"{repo_name} (+{stars_gained}⭐ {self.cfg.period})" + url = f"https://github.com/{repo_name}" + + content_lines = [ + f"Trending GitHub repo: {repo_name}", + f"Stars gained ({self.cfg.period}): {stars_gained}", + f"Forks gained: {row.get('forks', 0)}", + f"Pushes: {row.get('pushes', 0)}", + f"Pull requests: {row.get('pull_requests', 0)}", + f"Language: {primary_language}", + ] + if description: + content_lines.append("") + content_lines.append(description) + collections = row.get("collection_names") + if collections: + content_lines.append("") + content_lines.append(f"OSS Insight collections: {collections}") + + return ContentItem( + id=self._generate_id(SourceType.OSSINSIGHT.value, "trending", str(repo_id)), + source_type=SourceType.OSSINSIGHT, + title=title, + url=url, + content="\n".join(content_lines), + author=repo_name.split("/")[0] if "/" in repo_name else None, + published_at=datetime.now(timezone.utc), + metadata={ + "repo": repo_name, + "stars_gained": stars_gained, + "forks_gained": self._int(row.get("forks")), + "pushes": self._int(row.get("pushes")), + "pull_requests": self._int(row.get("pull_requests")), + "primary_language": primary_language, + "period": self.cfg.period, + "collection_names": collections, + "description": description, + "category": self.cfg.category, + }, + ) + + @staticmethod + def _stars_int(row: dict) -> int: + """Pull star count out of a row, coercing to int.""" + return OSSInsightScraper._int(row.get("stars")) + + @staticmethod + def _int(value) -> int: + """Best-effort conversion to int, defaulting to 0.""" + try: + return int(value) + except (TypeError, ValueError): + return 0 + + def _matches_keywords(self, row: dict) -> bool: + """Case-insensitive substring match against description, name, collections.""" + haystack = " ".join( + [ + (row.get("description") or "").lower(), + (row.get("collection_names") or "").lower(), + (row.get("repo_name") or "").lower(), + ] + ) + return any(kw in haystack for kw in self._keywords_lower) diff --git a/src/scrapers/reddit.py b/src/scrapers/reddit.py new file mode 100644 index 0000000..d73fdd3 --- /dev/null +++ b/src/scrapers/reddit.py @@ -0,0 +1,534 @@ +"""Reddit scraper implementation.""" + +import asyncio +import calendar +import logging +import re +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Any, List, Optional, cast + +import feedparser +import httpx +from bs4 import BeautifulSoup + +from .base import BaseScraper +from ..models import ( + ContentItem, + RedditConfig, + RedditSubredditConfig, + RedditUserConfig, + SourceType, +) + +logger = logging.getLogger(__name__) + +REDDIT_BASE = "https://www.reddit.com" +OLD_REDDIT_BASE = "https://old.reddit.com" +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" +) +REDDIT_HEADERS = { + "User-Agent": USER_AGENT, + "Accept": "application/json,text/plain,*/*", + "Accept-Language": "en-US,en;q=0.9", + "Referer": f"{REDDIT_BASE}/", +} +MAX_COMMENT_CONCURRENCY = 2 + + +class RedditBlockedError(Exception): + """Raised when Reddit blocks an unauthenticated JSON listing request.""" + + +class RedditScraper(BaseScraper): + """Scraper for Reddit posts and comments.""" + + def __init__(self, config: RedditConfig, http_client: httpx.AsyncClient): + super().__init__(config.model_dump(), http_client) + self.reddit_config = config + self._comment_semaphore = asyncio.Semaphore(MAX_COMMENT_CONCURRENCY) + + async def fetch(self, since: datetime) -> List[ContentItem]: + if not self.config.get("enabled", True): + return [] + + items = [] + for sub_cfg in self.reddit_config.subreddits: + if sub_cfg.enabled: + try: + items.extend(await self._fetch_subreddit(sub_cfg, since)) + except Exception as e: + logger.warning("Error fetching Reddit source: %s", e) + + for user_cfg in self.reddit_config.users: + if user_cfg.enabled: + try: + items.extend(await self._fetch_user(user_cfg, since)) + except Exception as e: + logger.warning("Error fetching Reddit source: %s", e) + + return items + + async def _fetch_subreddit( + self, cfg: RedditSubredditConfig, since: datetime + ) -> List[ContentItem]: + html_items = await self._fetch_subreddit_html(cfg, since) + if html_items: + return html_items + + logger.warning( + "Reddit old HTML returned no posts for r/%s; falling back to JSON", + cfg.subreddit, + ) + params: dict[str, Any] = {"limit": min(cfg.fetch_limit, 100), "raw_json": 1} + if cfg.sort in ("top", "controversial"): + params["t"] = cfg.time_filter + + url = f"{REDDIT_BASE}/r/{cfg.subreddit}/{cfg.sort}.json" + try: + data = await self._reddit_get(url, params) + except RedditBlockedError: + logger.warning( + "Reddit blocked JSON listing for r/%s; falling back to RSS", + cfg.subreddit, + ) + return await self._fetch_subreddit_rss(cfg, since) + if not data: + return [] + + posts = [ + child["data"] + for child in data.get("data", {}).get("children", []) + if child.get("kind") == "t3" + ] + return await self._process_posts( + posts, since, "subreddit", cfg.subreddit, cfg.min_score, cfg.category + ) + + async def _fetch_subreddit_rss( + self, cfg: RedditSubredditConfig, since: datetime + ) -> List[ContentItem]: + rss_url = f"{REDDIT_BASE}/r/{cfg.subreddit}/{cfg.sort}/.rss" + + try: + response = await self.client.get( + rss_url, + headers={ + **REDDIT_HEADERS, + "Accept": "application/atom+xml,application/xml,text/xml,*/*", + }, + follow_redirects=True, + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.warning("Reddit RSS fallback failed for r/%s: %s", cfg.subreddit, e) + return [] + + feed = feedparser.parse(response.text) + items = [] + for entry in feed.entries[: cfg.fetch_limit]: + published_at = self._parse_rss_date(entry) + if not published_at or published_at < since: + continue + + entry_id = str( + entry.get("id") or entry.get("link") or entry.get("title", "") + ) + title = str(entry.get("title") or "Untitled") + link = str(entry.get("link") or f"{REDDIT_BASE}/r/{cfg.subreddit}/") + content = self._strip_html(str(entry.get("summary") or "")) + + items.append( + ContentItem( + id=self._generate_id("reddit", "subreddit-rss", entry_id), + source_type=SourceType.REDDIT, + title=title, + url=cast(Any, link), + content=content, + author=str(entry.get("author") or "unknown"), + published_at=published_at, + metadata={ + "score": None, + "upvote_ratio": None, + "num_comments": None, + "subreddit": cfg.subreddit, + "is_self": None, + "flair": None, + "discussion_url": link, + "fallback": "rss", + "category": cfg.category, + }, + ) + ) + return items + + async def _fetch_subreddit_html( + self, cfg: RedditSubredditConfig, since: datetime + ) -> List[ContentItem]: + url = f"{OLD_REDDIT_BASE}/r/{cfg.subreddit}/{cfg.sort}/" + params: dict[str, Any] = {"limit": min(cfg.fetch_limit, 100)} + if cfg.sort in ("top", "controversial"): + params["t"] = cfg.time_filter + + try: + response = await self.client.get( + url, + params=params, + headers={ + **REDDIT_HEADERS, + "Accept": "text/html,application/xhtml+xml,*/*", + }, + follow_redirects=True, + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.warning( + "Reddit old HTML request failed for r/%s: %s", cfg.subreddit, e + ) + return [] + + posts = self._parse_old_reddit_posts(response.text, cfg) + return await self._process_posts( + posts, since, "subreddit-html", cfg.subreddit, cfg.min_score, cfg.category + ) + + def _parse_old_reddit_posts( + self, html: str, cfg: RedditSubredditConfig + ) -> List[dict]: + soup = BeautifulSoup(html, "html.parser") + posts = [] + for thing in soup.select("div.thing.link[data-fullname]")[: cfg.fetch_limit]: + fullname = str(thing.get("data-fullname") or "") + post_id = fullname.removeprefix("t3_") + title_el = thing.select_one("a.title") + if not post_id or not title_el: + continue + + permalink = str(thing.get("data-permalink") or "") + if permalink.startswith(REDDIT_BASE): + permalink = permalink.removeprefix(REDDIT_BASE) + if not permalink.startswith("/"): + permalink = f"/r/{cfg.subreddit}/comments/{post_id}/" + + url = str(thing.get("data-url") or "") + classes = thing.get("class") or [] + is_self = isinstance(classes, list) and "self" in classes or not url + selftext = "" + body_el = thing.select_one("div.expando div.usertext-body div.md") + if body_el: + selftext = body_el.get_text("\n", strip=True) + + posts.append( + { + "id": post_id, + "title": title_el.get_text(" ", strip=True), + "is_self": is_self, + "subreddit": str(thing.get("data-subreddit") or cfg.subreddit), + "permalink": permalink, + "author": str(thing.get("data-author") or "unknown"), + "created_utc": self._parse_old_reddit_timestamp(thing), + "score": self._parse_int(thing.get("data-score"), default=0), + "upvote_ratio": None, + "num_comments": self._parse_comment_count(thing), + "selftext": selftext, + "url": url or f"{REDDIT_BASE}{permalink}", + "link_flair_text": self._parse_flair(thing), + } + ) + return posts + + @staticmethod + def _parse_old_reddit_timestamp(thing: Any) -> float: + timestamp = thing.get("data-timestamp") + try: + return int(str(timestamp)) / 1000 + except (TypeError, ValueError): + pass + + time_el = thing.select_one("time[datetime]") + if time_el and time_el.get("datetime"): + try: + parsed = datetime.fromisoformat( + str(time_el["datetime"]).replace("Z", "+00:00") + ) + return parsed.timestamp() + except ValueError: + pass + return 0 + + @staticmethod + def _parse_int(value: Any, default: int = 0) -> int: + try: + return int(str(value).replace(",", "")) + except (TypeError, ValueError): + return default + + @classmethod + def _parse_comment_count(cls, thing: Any) -> int: + comments_el = thing.select_one("a.comments") + if not comments_el: + return 0 + match = re.search(r"[\d,]+", comments_el.get_text(" ", strip=True)) + return cls._parse_int(match.group(0), default=0) if match else 0 + + @staticmethod + def _parse_flair(thing: Any) -> Optional[str]: + flair_el = thing.select_one("span.linkflairlabel") + if not flair_el: + return None + flair = flair_el.get_text(" ", strip=True) + return flair or None + + async def _fetch_user( + self, cfg: RedditUserConfig, since: datetime + ) -> List[ContentItem]: + params: dict[str, Any] = { + "limit": min(cfg.fetch_limit, 100), + "sort": cfg.sort, + "raw_json": 1, + } + url = f"{REDDIT_BASE}/user/{cfg.username}/submitted.json" + data = await self._reddit_get(url, params) + if not data: + return [] + + posts = [ + child["data"] + for child in data.get("data", {}).get("children", []) + if child.get("kind") == "t3" + ] + return await self._process_posts( + posts, since, "user", cfg.username, min_score=0, category=cfg.category + ) + + async def _process_posts( + self, + posts: list, + since: datetime, + subtype: str, + source_name: str, + min_score: int, + category: Optional[str] = None, + ) -> List[ContentItem]: + valid_posts = [] + comment_tasks = [] + fetch_comments = self.reddit_config.fetch_comments + + for post in posts: + created = datetime.fromtimestamp( + post.get("created_utc", 0), tz=timezone.utc + ) + if created < since: + continue + if post.get("score", 0) < min_score: + continue + valid_posts.append(post) + if fetch_comments > 0: + comment_tasks.append( + self._fetch_comments(post.get("subreddit", ""), post["id"]) + ) + else: + comment_tasks.append(self._empty_comments()) + + if not valid_posts: + return [] + + all_comments = await asyncio.gather(*comment_tasks, return_exceptions=True) + + items = [] + for post, comments in zip(valid_posts, all_comments): + if isinstance(comments, Exception): + comments = [] + item = self._parse_post(post, cast(List[dict], comments), subtype, category) + if item: + items.append(item) + return items + + @staticmethod + async def _empty_comments() -> List[dict]: + return [] + + @staticmethod + def _parse_rss_date(entry: dict) -> Optional[datetime]: + for field in ["published", "updated", "created"]: + parsed_field = f"{field}_parsed" + if parsed_field in entry and entry[parsed_field]: + return datetime.fromtimestamp( + calendar.timegm(entry[parsed_field]), tz=timezone.utc + ) + if field in entry: + try: + parsed = parsedate_to_datetime(entry[field]) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + except Exception: + continue + return None + + @staticmethod + def _strip_html(value: str) -> str: + text = re.sub(r"<[^>]+>", " ", value) + return re.sub(r"\s+", " ", text).strip() + + async def _fetch_comments(self, subreddit: str, post_id: str) -> List[dict]: + fetch_limit = self.reddit_config.fetch_comments + html_comments = await self._fetch_comments_html(subreddit, post_id, fetch_limit) + if html_comments: + return html_comments + + url = f"{REDDIT_BASE}/r/{subreddit}/comments/{post_id}.json" + params = {"limit": fetch_limit, "depth": 1, "sort": "top", "raw_json": 1} + + async with self._comment_semaphore: + data = await self._reddit_get(url, params) + if not data or not isinstance(data, list) or len(data) < 2: + return [] + + comments = [] + for child in data[1].get("data", {}).get("children", []): + if child.get("kind") != "t1": + continue + c = child["data"] + if c.get("body") and not c.get("distinguished") == "moderator": + comments.append(c) + + comments.sort(key=lambda c: c.get("score", 0), reverse=True) + return comments[:fetch_limit] + + async def _fetch_comments_html( + self, subreddit: str, post_id: str, fetch_limit: int + ) -> List[dict]: + url = f"{OLD_REDDIT_BASE}/r/{subreddit}/comments/{post_id}/" + params = {"limit": fetch_limit, "sort": "top"} + + try: + response = await self.client.get( + url, + params=params, + headers={ + **REDDIT_HEADERS, + "Accept": "text/html,application/xhtml+xml,*/*", + }, + follow_redirects=True, + ) + response.raise_for_status() + except httpx.HTTPError as e: + logger.info("Reddit old HTML comments failed for %s: %s", post_id, e) + return [] + + soup = BeautifulSoup(response.text, "html.parser") + comments = [] + for comment_el in soup.select("div.comment[data-fullname]"): + classes = comment_el.get("class") or [] + if isinstance(classes, list) and "deleted" in classes: + continue + body_el = comment_el.select_one("div.usertext-body div.md") + if not body_el: + continue + body = body_el.get_text("\n", strip=True) + if not body: + continue + comments.append( + { + "author": str(comment_el.get("data-author") or "anon"), + "body": body, + "score": self._parse_int(comment_el.get("data-score"), default=0), + } + ) + + comments.sort(key=lambda c: c.get("score", 0), reverse=True) + return comments[:fetch_limit] + + def _parse_post( + self, post: dict, comments: List[dict], subtype: str, category: Optional[str] = None + ) -> Optional[ContentItem]: + post_id = post["id"] + title = post.get("title", "") + is_self = post.get("is_self", False) + subreddit = post.get("subreddit", "") + discussion_url = f"https://www.reddit.com{post.get('permalink', '')}" + + # For link posts, use the external URL; for self posts, use the discussion URL + url = discussion_url if is_self else post.get("url", discussion_url) + + author = post.get("author", "unknown") + created = datetime.fromtimestamp(post.get("created_utc", 0), tz=timezone.utc) + + # Build content + parts = [] + if post.get("selftext"): + text = post["selftext"] + if len(text) > 1500: + text = text[:1497] + "..." + parts.append(text) + + if comments: + parts.append("\n--- Top Comments ---") + for c in comments: + commenter = c.get("author", "anon") + body = c.get("body", "") + body = body.strip() + if len(body) > 500: + body = body[:497] + "..." + score = c.get("score", 0) + parts.append(f"[{commenter} ({score} pts)]: {body}") + + content = "\n\n".join(parts) + + return ContentItem( + id=self._generate_id("reddit", subtype, post_id), + source_type=SourceType.REDDIT, + title=title, + url=cast(Any, url), + content=content, + author=author, + published_at=created, + metadata={ + "score": post.get("score", 0), + "upvote_ratio": post.get("upvote_ratio"), + "num_comments": post.get("num_comments", 0), + "subreddit": subreddit, + "is_self": is_self, + "flair": post.get("link_flair_text"), + "discussion_url": discussion_url, + "category": category, + }, + ) + + async def _reddit_get(self, url: str, params: dict) -> Optional[Any]: + try: + response = await self.client.get( + url, + params=params, + headers=REDDIT_HEADERS, + follow_redirects=True, + ) + if response.status_code == 429: + retry_after = int(response.headers.get("Retry-After", 5)) + logger.warning("Reddit rate limited, retrying after %ds", retry_after) + await asyncio.sleep(retry_after) + response = await self.client.get( + url, + params=params, + headers=REDDIT_HEADERS, + follow_redirects=True, + ) + if response.status_code == 403 and "/comments/" in url: + logger.info( + "Reddit blocked comments request for %s; continuing without comments", + url, + ) + return None + if response.status_code == 403: + raise RedditBlockedError(url) + response.raise_for_status() + return response.json() + except RedditBlockedError: + raise + except httpx.HTTPError as e: + logger.warning("Reddit request failed for %s: %s", url, e) + return None diff --git a/src/scrapers/rss.py b/src/scrapers/rss.py new file mode 100644 index 0000000..1603b6a --- /dev/null +++ b/src/scrapers/rss.py @@ -0,0 +1,182 @@ +"""RSS feed scraper implementation.""" + +import calendar +import hashlib +import logging +import os +import re +from datetime import datetime, timezone +from typing import List, Optional +from email.utils import parsedate_to_datetime +import httpx +import feedparser + +from .base import BaseScraper +from ..extractors import ExtractorRegistry +from ..models import ContentItem, SourceType, RSSSourceConfig + +logger = logging.getLogger(__name__) + + +class RSSScraper(BaseScraper): + """Scraper for RSS/Atom feeds.""" + + def __init__( + self, + sources: List[RSSSourceConfig], + http_client: httpx.AsyncClient, + extractors: Optional[ExtractorRegistry] = None, + ): + """Initialize RSS scraper. + + Args: + sources: List of RSS feed configurations + http_client: Shared async HTTP client + extractors: Optional registry of content extractors for full article fetching + """ + super().__init__({"sources": sources}, http_client) + self._extractors = extractors + + async def fetch(self, since: datetime) -> List[ContentItem]: + """Fetch RSS feed items. + + Args: + since: Only fetch items published after this time + + Returns: + List[ContentItem]: Fetched content items + """ + items = [] + sources = self.config["sources"] + + for source in sources: + if not source.enabled: + continue + + feed_items = await self._fetch_feed(source, since) + items.extend(feed_items) + + return items + + async def _fetch_feed( + self, source: RSSSourceConfig, since: datetime + ) -> List[ContentItem]: + """Fetch items from a single RSS feed. + + Args: + source: RSS feed configuration + since: Only fetch items after this time + + Returns: + List[ContentItem]: Feed content items + """ + items = [] + + try: + # Expand environment variables in URL (e.g. ${LWN_TOKEN}) + feed_url = re.sub( + r"\$\{(\w+)\}", + lambda m: os.environ.get(m.group(1), m.group(0)).strip(), + str(source.url), + ) + + # Fetch feed content + response = await self.client.get(feed_url, follow_redirects=True) + response.raise_for_status() + + # Parse feed + feed = feedparser.parse(response.text) + + for entry in feed.entries: + # Parse published date + published_at = self._parse_date(entry) + if not published_at or published_at < since: + continue + + # Generate unique ID from feed URL and entry ID + feed_id = str(source.url).split("//")[1].replace("/", "_") + entry_id = entry.get("id", entry.get("link", "")) + entry_hash = hashlib.sha256(str(entry_id).encode("utf-8")).hexdigest()[ + :16 + ] + + # Extract content + content = self._extract_content(entry) + + if source.content_extractor and self._extractors: + extractor = self._extractors.get(source.content_extractor) + if extractor: + url = entry.get("link", "") + if url: + full = await extractor.extract(url, self.client) + if full: + content = full + + item = ContentItem( + id=self._generate_id("rss", feed_id, entry_hash), + source_type=SourceType.RSS, + title=entry.get("title", "Untitled"), + url=entry.get("link", str(source.url)), + content=content, + author=entry.get("author", source.name), + published_at=published_at, + metadata={ + "feed_name": source.name, + "category": source.category, + "tags": [tag.term for tag in entry.get("tags", [])], + }, + ) + items.append(item) + + except httpx.HTTPError as e: + logger.warning("Error fetching RSS feed %s: %s", source.name, e) + except Exception as e: + logger.warning("Error parsing RSS feed %s: %s", source.name, e) + + return items + + def _parse_date(self, entry: dict) -> datetime: + """Parse publication date from feed entry. + + Args: + entry: Feed entry data + + Returns: + datetime: Parsed publication date or None + """ + # Try different date fields + for field in ["published", "updated", "created"]: + if field in entry: + try: + # Try parsing structured time first + if f"{field}_parsed" in entry and entry[f"{field}_parsed"]: + return datetime.fromtimestamp( + calendar.timegm(entry[f"{field}_parsed"]), tz=timezone.utc + ) + # Fallback to string parsing + date_str = entry[field] + return parsedate_to_datetime(date_str) + except Exception: + continue + + return None + + def _extract_content(self, entry: dict) -> str: + """Extract text content from feed entry. + + Args: + entry: Feed entry data + + Returns: + str: Extracted text content + """ + # Try different content fields + if "summary" in entry: + return entry.summary + if "description" in entry: + return entry.description + if "content" in entry and entry.content: + # content is usually a list + return entry.content[0].get("value", "") + + return "" diff --git a/src/scrapers/telegram.py b/src/scrapers/telegram.py new file mode 100644 index 0000000..c84af3c --- /dev/null +++ b/src/scrapers/telegram.py @@ -0,0 +1,152 @@ +"""Telegram public channel scraper implementation.""" + +import asyncio +import logging +import re +from datetime import datetime, timezone +from typing import List, Optional + +import httpx +from bs4 import BeautifulSoup + +from .base import BaseScraper +from ..models import ContentItem, TelegramConfig, TelegramChannelConfig, SourceType + +logger = logging.getLogger(__name__) + +TELEGRAM_WEB_BASE = "https://t.me/s" +USER_AGENT = "Mozilla/5.0 (compatible; Horizon/1.0; +https://github.com/thysrael/horizon)" + + +class TelegramScraper(BaseScraper): + """Scraper for Telegram public channels via web preview.""" + + def __init__(self, config: TelegramConfig, http_client: httpx.AsyncClient): + super().__init__(config.model_dump(), http_client) + self.telegram_config = config + + async def fetch(self, since: datetime) -> List[ContentItem]: + if not self.config.get("enabled", True): + return [] + + tasks = [] + for channel_cfg in self.telegram_config.channels: + if channel_cfg.enabled: + tasks.append(self._fetch_channel(channel_cfg, since)) + + if not tasks: + return [] + + results = await asyncio.gather(*tasks, return_exceptions=True) + items = [] + for result in results: + if isinstance(result, Exception): + logger.warning("Error fetching Telegram channel: %s", result) + elif isinstance(result, list): + items.extend(result) + return items + + async def _fetch_channel(self, cfg: TelegramChannelConfig, since: datetime) -> List[ContentItem]: + url = f"{TELEGRAM_WEB_BASE}/{cfg.channel}" + headers = {"User-Agent": USER_AGENT} + try: + response = await self.client.get(url, headers=headers, follow_redirects=True, timeout=120.0) + if response.status_code == 429: + retry_after = int(response.headers.get("Retry-After", 5)) + logger.warning("Telegram rate limited for %s, retrying after %ds", cfg.channel, retry_after) + await asyncio.sleep(retry_after) + response = await self.client.get(url, headers=headers, follow_redirects=True, timeout=120.0) + response.raise_for_status() + except Exception as e: + logger.warning("Telegram request failed for %s: [%s] %r", cfg.channel, type(e).__name__, e) + return [] + + return self._parse_channel_html(response.text, cfg, since) + + def _parse_channel_html( + self, html: str, cfg: TelegramChannelConfig, since: datetime + ) -> List[ContentItem]: + soup = BeautifulSoup(html, "html.parser") + messages = soup.select("div.tgme_widget_message[data-post]") + + items = [] + for msg in messages[-cfg.fetch_limit:]: + item = self._parse_message(msg, cfg, since) + if item: + items.append(item) + return items + + def _parse_message( + self, msg_el, cfg: TelegramChannelConfig, since: datetime + ) -> Optional[ContentItem]: + channel = cfg.channel + # Extract message ID + data_post = msg_el.get("data-post", "") + msg_id = data_post.split("/")[-1] if "/" in data_post else data_post + if not msg_id: + return None + + # Extract timestamp + time_el = msg_el.select_one("time[datetime]") + if not time_el: + return None + try: + published_at = datetime.fromisoformat(time_el["datetime"].replace("Z", "+00:00")) + except (ValueError, KeyError): + return None + + if published_at < since: + return None + + # Extract message text + text_el = msg_el.select_one("div.tgme_widget_message_text.js-message_text") + if not text_el: + return None + + # Convert
    to newlines, then strip tags + for br in text_el.find_all("br"): + br.replace_with("\n") + raw_text = text_el.get_text(separator="") + text = raw_text.strip() + + if not text: + return None + + # Generate title from first paragraph + title = self._make_title(text) + + # Find first external link as canonical URL; fallback to message URL + msg_url = f"https://t.me/{channel}/{msg_id}" + canonical_url = msg_url + for a in text_el.find_all("a", href=True): + href = a["href"] + if href.startswith("http") and "t.me" not in href: + canonical_url = href + break + + return ContentItem( + id=self._generate_id("telegram", channel, msg_id), + source_type=SourceType.TELEGRAM, + title=title, + url=canonical_url, + content=text, + author=channel, + published_at=published_at, + metadata={"msg_url": msg_url, "channel": channel, "category": cfg.category}, + ) + + @staticmethod + def _make_title(text: str) -> str: + # Take first paragraph (split on double newline) + first_para = text.split("\n\n")[0].replace("\n", " ").strip() + + if len(first_para) <= 80: + return first_para + + # Try to break at a Chinese sentence-ending punctuation within first 80 chars + match = re.search(r"[。!?]", first_para[:80]) + if match: + return first_para[: match.end()] + + # Fallback: hard truncate + return first_para[:80] diff --git a/src/scrapers/twitter.py b/src/scrapers/twitter.py new file mode 100644 index 0000000..d303ca7 --- /dev/null +++ b/src/scrapers/twitter.py @@ -0,0 +1,314 @@ +"""Twitter scraper using Apify altimis/scweet actor.""" + +import asyncio +import logging +import os +from datetime import datetime, timezone +from html import unescape +from typing import List, Optional + +from dateutil.parser import isoparse +import httpx + +from .base import BaseScraper +from ..models import ContentItem, SourceType, TwitterConfig + +logger = logging.getLogger(__name__) + +_APIFY_BASE = "https://api.apify.com/v2" +_POLL_INTERVAL = 3.0 +_MAX_WAIT = 180 + + +class TwitterScraper(BaseScraper): + """Fetch tweets via the Apify altimis/scweet actor.""" + + def __init__(self, config: TwitterConfig, http_client: httpx.AsyncClient): + super().__init__(config, http_client) + self.config = config + + async def fetch(self, since: datetime) -> List[ContentItem]: + if not self.config.enabled: + return [] + + users = [u.strip().lstrip("@") for u in self.config.users if u.strip()] + if not users: + logger.debug("No Twitter users configured, skipping.") + return [] + + token = os.environ.get(self.config.apify_token_env) + if not token: + logger.warning( + f"Apify token not found in env var '{self.config.apify_token_env}'. Skipping Twitter." + ) + return [] + + logger.info(f"Fetching Twitter (Apify) for users: {users}") + + run_id, dataset_id = await self._start_run(token, users) + if not run_id: + return [] + + succeeded = await self._wait_for_run(token, run_id) + if not succeeded: + return [] + + raw_items = await self._fetch_dataset(token, dataset_id) + items = [] + for raw in raw_items: + if isinstance(raw, dict) and raw.get("noResults"): + continue + parsed = self._parse_item(raw, since) + if parsed: + items.append(parsed) + + logger.info(f"Fetched {len(items)} tweets via Apify.") + return items + + async def _start_run( + self, token: str, users: List[str] + ) -> tuple[Optional[str], Optional[str]]: + payload = { + "source_mode": "profiles", + "profile_urls": users, + "search_sort": "Latest", + "max_items": max(100, self.config.fetch_limit), + } + url = f"{_APIFY_BASE}/acts/{self.config.actor_id}/runs?token={token}" + try: + resp = await self.client.post(url, json=payload, timeout=30.0) + resp.raise_for_status() + data = resp.json()["data"] + run_id = data["id"] + dataset_id = data["defaultDatasetId"] + logger.debug(f"Started Apify run {run_id}, dataset {dataset_id}") + return run_id, dataset_id + except Exception as exc: + logger.error(f"Failed to start Apify run: {exc}") + return None, None + + async def _wait_for_run(self, token: str, run_id: str) -> bool: + url = f"{_APIFY_BASE}/actor-runs/{run_id}?token={token}" + elapsed = 0.0 + while elapsed < _MAX_WAIT: + try: + resp = await self.client.get(url, timeout=10.0) + resp.raise_for_status() + status = resp.json()["data"]["status"] + if status == "SUCCEEDED": + return True + if status in ("FAILED", "ABORTED", "TIMED-OUT"): + logger.error(f"Apify run {run_id} ended with status: {status}") + return False + except Exception as exc: + logger.warning(f"Error polling Apify run {run_id}: {exc}") + await asyncio.sleep(_POLL_INTERVAL) + elapsed += _POLL_INTERVAL + logger.warning(f"Apify run {run_id} timed out after {_MAX_WAIT}s.") + return False + + async def _fetch_dataset(self, token: str, dataset_id: str) -> list: + url = f"{_APIFY_BASE}/datasets/{dataset_id}/items?token={token}" + try: + resp = await self.client.get(url, timeout=30.0) + resp.raise_for_status() + return resp.json() + except Exception as exc: + logger.error(f"Failed to fetch Apify dataset {dataset_id}: {exc}") + return [] + + async def fetch_replies_for_item(self, item: ContentItem) -> List[str]: + """Fetch reply texts for one tweet using scweet search mode.""" + if not self.config.fetch_reply_text: + return [] + + token = os.environ.get(self.config.apify_token_env) + if not token: + return [] + + conversation_id = str(item.metadata.get("conversation_id") or "") + if not conversation_id: + return [] + + max_replies = max(self.config.max_replies_per_tweet, 0) + if max_replies == 0: + return [] + + max_items = max(100, max_replies * 5) + payload = { + "source_mode": "search", + "search_query": f"conversation_id:{conversation_id}", + "search_sort": "Latest", + "max_items": max_items, + } + + url = f"{_APIFY_BASE}/acts/{self.config.actor_id}/runs?token={token}" + try: + resp = await self.client.post(url, json=payload, timeout=30.0) + resp.raise_for_status() + data = resp.json()["data"] + run_id = data["id"] + dataset_id = data["defaultDatasetId"] + except Exception as exc: + logger.warning(f"Failed to start replies run for {item.id}: {exc}") + return [] + + if not await self._wait_for_run(token, run_id): + return [] + + rows = await self._fetch_dataset(token, dataset_id) + return self._extract_reply_lines(item, rows, max_replies) + + def _extract_reply_lines(self, item: ContentItem, rows: list, max_replies: int) -> List[str]: + """Convert scweet rows into compact reply lines.""" + min_likes = max(self.config.reply_min_likes, 0) + tweet_id = str(item.metadata.get("tweet_id") or "") + own_author = (item.author or "").lstrip("@") + candidates = [] + + for row in rows: + if not isinstance(row, dict) or row.get("noResults"): + continue + + row_id = str(row.get("id") or "") + if row_id.startswith("tweet-"): + row_id = row_id[6:] + if tweet_id and row_id == tweet_id: + continue + + user = row.get("user") or {} + handle = ( + user.get("handle") + or row.get("handle") + or user.get("username") + or "unknown" + ) + if handle and own_author and handle.lower() == own_author.lower(): + continue + + text = unescape((row.get("text") or "").strip()) + if not text: + continue + + likes = int(row.get("favorite_count") or 0) + replies = int(row.get("reply_count") or 0) + if likes < min_likes: + continue + + score = likes * 2 + replies + line = f"[@{handle} | ❤️ {likes} | 💬 {replies}] {text[:280]}" + candidates.append((score, line)) + + candidates.sort(key=lambda x: x[0], reverse=True) + return [line for _, line in candidates[:max_replies]] + + @staticmethod + def append_discussion_content(item: ContentItem, reply_lines: List[str]) -> bool: + """Append reply lines under Top Comments marker.""" + if not reply_lines: + return False + + existing = item.content or "" + marker = "--- Top Comments ---" + block = "\n".join(reply_lines) + + if marker in existing: + if block in existing: + return False + item.content = existing + "\n" + block + return True + + if existing: + item.content = existing + f"\n\n{marker}\n" + block + else: + item.content = f"{marker}\n" + block + return True + + def _parse_item(self, item: dict, since: datetime) -> Optional[ContentItem]: + try: + created_at_str = item.get("created_at") + if not created_at_str: + return None + + try: + published_at = datetime.strptime( + created_at_str, "%a %b %d %H:%M:%S %z %Y" + ) + except ValueError: + published_at = isoparse(created_at_str) + + if published_at.tzinfo is None: + published_at = published_at.replace(tzinfo=timezone.utc) + + if published_at < since: + return None + + tweet_id = str(item.get("id_str") or item.get("id") or "") + if not tweet_id: + return None + + # Normalize tweet_id: scweet prefixes with "tweet-" + raw_id = item.get("id") or "" + numeric_id = ( + str(raw_id).replace("tweet-", "") + if str(raw_id).startswith("tweet-") + else tweet_id + ) + conversation_id = str( + item.get("conversation_id") + or item.get("tweet", {}).get("conversation_id") + or numeric_id + ) + + user = item.get("user") or {} + screen_name = ( + user.get("screen_name") + or user.get("username") + or user.get("handle") + or item.get("handle") + or item.get("username") + or "unknown" + ) + author = user.get("name") or screen_name + + text = item.get("full_text") or item.get("text") or "" + if not text: + return None + text = unescape(text) + + url = item.get("url") + if not url: + permalink = item.get("permalink") + if permalink and screen_name != "unknown": + url = f"https://twitter.com/{screen_name}{permalink}" + else: + url = f"https://twitter.com/{screen_name}/status/{tweet_id}" + + title_body = text[:50].replace("\n", " ").strip() + if len(text) > 50: + title_body += "..." + + return ContentItem( + id=self._generate_id(SourceType.TWITTER.value, "tweet", numeric_id), + source_type=SourceType.TWITTER, + title=f"@{screen_name}: {title_body}", + url=url, + content=text, + author=author, + published_at=published_at, + metadata={ + "tweet_id": numeric_id, + "conversation_id": conversation_id, + "favorite_count": item.get("favorite_count", 0), + "retweet_count": item.get("retweet_count", 0), + "reply_count": item.get("reply_count", 0), + "view_count": item.get("view_count"), + "is_reply": item.get("is_reply", False), + "in_reply_to_status_id": item.get("in_reply_to_status_id"), + "in_reply_to_screen_name": item.get("in_reply_to_screen_name"), + "category": self.config.category, + }, + ) + except Exception as exc: + logger.debug(f"Failed to parse tweet: {exc}") + return None diff --git a/src/scrapers/twitter_playwright.py b/src/scrapers/twitter_playwright.py new file mode 100644 index 0000000..604437c --- /dev/null +++ b/src/scrapers/twitter_playwright.py @@ -0,0 +1,390 @@ +"""Twitter scraper using Playwright + Cookie (replaces Apify).""" + +import asyncio +import glob +import hashlib +import json +import logging +import os +import random +from datetime import datetime, timezone +from pathlib import Path +from typing import List, Optional + +from ..models import ContentItem, SourceType, TwitterConfig +from .base import BaseScraper + +logger = logging.getLogger(__name__) + +# Optional Playwright imports — gracefully degraded if not installed +try: + from playwright.async_api import async_playwright + from playwright_stealth import Stealth + + PLAYWRIGHT_AVAILABLE = True +except ImportError: + PLAYWRIGHT_AVAILABLE = False + Stealth = None # type: ignore[misc,assignment] + + +def _get_proxy() -> str: + """Resolve proxy from common env vars (PROXY, http_proxy, all_proxy).""" + for key in ("PROXY", "https_proxy", "http_proxy", "all_proxy"): + val = os.getenv(key, "").strip() + if val: + return val + return "" + + +PROXY = _get_proxy() + + +def _load_browser_cookies(file_path: str) -> list[dict]: + """Read browser-exported cookie JSON and convert to Playwright format.""" + if not Path(file_path).exists(): + return [] + with open(file_path, encoding="utf-8") as f: + cookies = json.load(f) + pw_cookies = [] + for c in cookies: + pc: dict = { + "name": c["name"], + "value": c["value"], + "domain": c["domain"], + "path": c.get("path", "/"), + "secure": c.get("secure", True), + "httpOnly": c.get("httpOnly", False), + } + if c.get("expirationDate"): + pc["expires"] = c["expirationDate"] + pw_cookies.append(pc) + return pw_cookies + + +class TwitterPlaywrightScraper(BaseScraper): + """Fetch tweets via Playwright + Cookie using GraphQL interception (free alternative to Apify).""" + + def __init__(self, config: TwitterConfig, http_client=None): + super().__init__(config.model_dump(), http_client) + self.twitter_config = config + + async def fetch(self, since: datetime) -> List[ContentItem]: + if not self.twitter_config.enabled: + return [] + + users = [u.strip().lstrip("@") for u in self.twitter_config.users if u.strip()] + if not users: + logger.debug("No Twitter users configured, skipping.") + return [] + + if not PLAYWRIGHT_AVAILABLE: + logger.warning( + "Playwright not installed. Run: uv sync --extra twitter && uv run playwright install chromium" + ) + return [] + + cookie_dir = Path(self.twitter_config.cookie_dir) + pattern = self.twitter_config.cookie_file_pattern + cookie_files = sorted(cookie_dir.glob(pattern)) + if not cookie_files: + logger.warning("No cookie files found matching %s in %s", pattern, cookie_dir) + return [] + + logger.info( + "Fetching Twitter (Playwright) for %d users using %d cookie sets", + len(users), + len(cookie_files), + ) + + all_items: List[ContentItem] = [] + failed_users: list[tuple[str, int]] = [] + lock = asyncio.Lock() + + async with Stealth().use_async(async_playwright()) as p: # type: ignore[union-attr] + launch_kwargs: dict = {"headless": True} + if PROXY: + launch_kwargs["proxy"] = {"server": PROXY} + browser = await p.chromium.launch(**launch_kwargs) + + contexts = [] + for i, cf in enumerate(cookie_files): + cookies = _load_browser_cookies(str(cf)) + ctx = await browser.new_context( + user_agent=( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + f"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/13{i}.0.0.0 Safari/537.36" + ), + viewport={"width": 1280, "height": 800}, + locale="en-US", + timezone_id="UTC", + color_scheme="dark", + ) + if cookies: + await ctx.add_cookies(cookies) + contexts.append(ctx) + + # Warm-up each context by visiting x.com/home + for i, ctx in enumerate(contexts): + page = await ctx.new_page() + try: + await page.goto("https://x.com/home", wait_until="domcontentloaded", timeout=15000) + await asyncio.sleep(2) + logger.info("Cookie #%d warm-up done", i + 1) + except Exception as exc: + logger.warning("Cookie #%d warm-up failed: %s", i + 1, exc) + finally: + await page.close() + + num_contexts = len(contexts) + + async def process_queue(context_idx: int, queue: list[str], is_retry: bool = False): + ctx = contexts[context_idx] + consecutive_failures = 0 + + for username in queue: + wait_time = ( + random.uniform(5.0, 10.0) if not is_retry else random.uniform(10.0, 20.0) + ) + await asyncio.sleep(wait_time) + + if consecutive_failures >= 5: + logger.warning("Context #%d cooling down (30s)", context_idx + 1) + await asyncio.sleep(30) + consecutive_failures = 0 + + logger.info("Scraping @%s with cookie #%d...", username, context_idx + 1) + tweets = await self._scrape_user(ctx, username, since) + + if tweets is not None: + logger.info(" -> @%s: %d tweets found", username, len(tweets)) + consecutive_failures = 0 + parsed = [item for item in (self._parse_tweet(t, username) for t in tweets) if item] + async with lock: + all_items.extend(parsed) + else: + consecutive_failures += 1 + if not is_retry: + async with lock: + failed_users.append((username, context_idx)) + + # Round-robin assign users to context queues + queues: list[list[str]] = [[] for _ in range(num_contexts)] + for i, username in enumerate(users): + queues[i % num_contexts].append(username) + + # First pass — all queues in parallel + await asyncio.gather(*[process_queue(i, q) for i, q in enumerate(queues)]) + + # Retry failed users with a different context + if failed_users: + logger.info("Retrying %d failed Twitter accounts with alternate cookies", len(failed_users)) + await asyncio.sleep(20) + retry_queues: list[list[str]] = [[] for _ in range(num_contexts)] + for username, original_idx in failed_users: + new_idx = (original_idx + 1) % num_contexts if num_contexts >= 2 else 0 + retry_queues[new_idx].append(username) + + await asyncio.gather(*[ + process_queue(i, q, is_retry=True) + for i, q in enumerate(retry_queues) + if q + ]) + + for ctx in contexts: + await ctx.close() + await browser.close() + + logger.info("Fetched %d tweets via Playwright.", len(all_items)) + return all_items + + async def _scrape_user(self, ctx, username: str, since: datetime) -> Optional[list[dict]]: + """Scrape a single user's tweets via GraphQL interception.""" + page = await ctx.new_page() + graphql_tweets: list[dict] = [] + + async def handle_response(response): + if "UserTweets" not in response.url and "UserByScreenName" not in response.url: + return + try: + data = await response.json() + + def extract_tweets(obj): + if isinstance(obj, dict): + if obj.get("rest_id") and obj.get("legacy"): + legacy = obj["legacy"] + media_entities = ( + legacy.get("extended_entities", {}).get("media", []) + or legacy.get("entities", {}).get("media", []) + ) + images = [ + m["media_url_https"] + for m in media_entities + if m.get("type") == "photo" and m.get("media_url_https") + ] + tweet = { + "tweet_id": obj["rest_id"], + "text": legacy.get("full_text", ""), + "datetime_raw": legacy.get("created_at", ""), + "is_retweet": ( + "retweeted_status_result" in obj.get("core", {}) + or "retweeted_status_id_str" in legacy + ), + "images": images, + } + try: + dt = datetime.strptime(tweet["datetime_raw"], "%a %b %d %H:%M:%S %z %Y") + tweet["datetime"] = dt.isoformat() + except (ValueError, TypeError): + tweet["datetime"] = tweet["datetime_raw"] + graphql_tweets.append(tweet) + for v in obj.values(): + extract_tweets(v) + elif isinstance(obj, list): + for item in obj: + extract_tweets(item) + + extract_tweets(data) + except Exception as exc: + logger.debug("GraphQL parse error: %s", exc) + + page.on("response", handle_response) + + # Block heavy resources + async def route_handler(route): + if route.request.resource_type in ("media", "image", "video"): + await route.abort() + else: + url = route.request.url.lower() + if any(k in url for k in ("google-analytics", "doubleclick", "scribe.twitter.com")): + await route.abort() + else: + await route.continue_() + + await page.route("**/*", route_handler) + + try: + await asyncio.sleep(random.uniform(2, 4)) + + for attempt in range(3): + if attempt > 0: + await asyncio.sleep(random.uniform(5, 10)) + try: + await page.goto( + f"https://x.com/{username}", + wait_until="domcontentloaded", + timeout=25000, + ) + break + except Exception as exc: + if "Timeout" in str(exc): + logger.debug("Page load slow (attempt %d/3)", attempt + 1) + if attempt == 2: + break + else: + if attempt == 2: + raise + logger.debug("Page visit error (attempt %d/3): %s", attempt + 1, exc) + + await asyncio.sleep(5) + start_time = asyncio.get_event_loop().time() + + # Quick diagnostic: check if page requires login + body_text = await page.evaluate("document.body ? document.body.innerText : ''") + if body_text and any(k in body_text.lower() for k in ("log in", "sign up", "create account")): + logger.warning(" -> @%s page shows login gate — cookie may be invalid", username) + + while (asyncio.get_event_loop().time() - start_time) < 60: + if graphql_tweets: + result = [] + seen = set() + for t in graphql_tweets: + uid = t.get("tweet_id") or hashlib.md5(t["text"].encode()).hexdigest() + if uid in seen: + continue + seen.add(uid) + try: + tweet_time = datetime.fromisoformat(t["datetime"]) + if tweet_time < since: + continue + except Exception: + continue + result.append(t) + + if result: + logger.info(" -> @%s: %d tweets within time window", username, len(result)) + return result[: self.twitter_config.fetch_limit] + logger.info(" -> @%s: intercepted %d tweets but all outside time window", username, len(graphql_tweets)) + return [] + + # Check for error pages + body_text = await page.evaluate("document.body ? document.body.innerText : ''") + if body_text and any( + kw in body_text + for kw in ("Retry", "Something went wrong", "出错了", "重新加载") + ): + await page.reload(wait_until="load", timeout=30000) + await asyncio.sleep(5) + + # Simulate human browsing + await page.mouse.move(random.randint(100, 600), random.randint(100, 600)) + await page.evaluate(f"window.scrollBy(0, {random.randint(300, 700)})") + await asyncio.sleep(random.uniform(2, 4)) + + at_bottom = await page.evaluate( + "window.innerHeight + window.scrollY >= document.body.scrollHeight" + ) + if at_bottom and (asyncio.get_event_loop().time() - start_time) > 20: + break + + if not graphql_tweets: + logger.warning(" -> @%s: no GraphQL data intercepted (cookie or page issue)", username) + return None + return [] + + except Exception as exc: + logger.warning("Failed to scrape @%s: %s", username, exc) + return None + finally: + await page.close() + + def _parse_tweet(self, tweet: dict, username: str) -> Optional[ContentItem]: + """Convert raw tweet dict to Horizon ContentItem.""" + try: + tweet_id = str(tweet.get("tweet_id", "")) + if not tweet_id: + return None + + text = tweet.get("text", "") + if not text: + return None + + created_at_raw = tweet.get("datetime", "") + try: + published_at = datetime.fromisoformat(created_at_raw) + if published_at.tzinfo is None: + published_at = published_at.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + return None + + title_body = text[:50].replace("\n", " ").strip() + if len(text) > 50: + title_body += "..." + + return ContentItem( + id=self._generate_id(SourceType.TWITTER.value, "tweet", tweet_id), + source_type=SourceType.TWITTER, + title=f"@{username}: {title_body}", + url=f"https://x.com/{username}/status/{tweet_id}", + content=text, + author=username, + published_at=published_at, + metadata={ + "tweet_id": tweet_id, + "is_retweet": tweet.get("is_retweet", False), + "images": tweet.get("images", []), + "category": self.twitter_config.category, + }, + ) + except Exception as exc: + logger.debug("Failed to parse tweet: %s", exc) + return None diff --git a/src/search.py b/src/search.py new file mode 100644 index 0000000..7386443 --- /dev/null +++ b/src/search.py @@ -0,0 +1,106 @@ +"""Search HN Algolia and Reddit for related stories.""" + +import asyncio +from typing import List, Dict + +import httpx + +from .models import ContentItem + +HN_SEARCH_URL = "https://hn.algolia.com/api/v1/search" +REDDIT_SEARCH_URL = "https://www.reddit.com/search.json" + +_reddit_semaphore = asyncio.Semaphore(5) + + +async def search_hn(query: str, client: httpx.AsyncClient) -> List[dict]: + """Search HN Algolia. Returns list of {title, url, source, score, num_comments, date}.""" + params = {"query": query, "tags": "story", "hitsPerPage": 3} + try: + resp = await client.get(HN_SEARCH_URL, params=params) + resp.raise_for_status() + data = resp.json() + except Exception: + return [] + + results = [] + for hit in data.get("hits", []): + results.append({ + "title": hit.get("title", ""), + "url": hit.get("url") or f"https://news.ycombinator.com/item?id={hit.get('objectID', '')}", + "source": "hackernews", + "score": hit.get("points", 0), + "num_comments": hit.get("num_comments", 0), + "date": hit.get("created_at", ""), + }) + return results + + +async def search_reddit(query: str, client: httpx.AsyncClient) -> List[dict]: + """Search Reddit JSON API. Returns list of {title, url, source, score, num_comments, subreddit, date}.""" + params = {"q": query, "sort": "relevance", "limit": 3, "t": "year"} + headers = {"User-Agent": "Horizon/1.0 (tech news aggregator)"} + try: + async with _reddit_semaphore: + resp = await client.get(REDDIT_SEARCH_URL, params=params, headers=headers) + resp.raise_for_status() + data = resp.json() + except Exception: + return [] + + results = [] + for child in data.get("data", {}).get("children", []): + post = child.get("data", {}) + results.append({ + "title": post.get("title", ""), + "url": post.get("url", ""), + "source": "reddit", + "score": post.get("score", 0), + "num_comments": post.get("num_comments", 0), + "subreddit": post.get("subreddit", ""), + "date": post.get("created_utc", ""), + }) + return results + + +async def search_related( + items: List[ContentItem], client: httpx.AsyncClient +) -> Dict[str, List[dict]]: + """Search HN + Reddit for each item concurrently. + + Returns {item.id: [related_stories]}. + Deduplicates by URL against each item's own URL. + """ + + async def _search_for_item(item: ContentItem) -> tuple: + query = item.title + hn_results, reddit_results = await asyncio.gather( + search_hn(query, client), + search_reddit(query, client), + return_exceptions=True, + ) + # Treat exceptions as empty results + if isinstance(hn_results, Exception): + hn_results = [] + if isinstance(reddit_results, Exception): + reddit_results = [] + + # Dedup: remove results whose URL matches the item's own URL + item_url = str(item.url).rstrip("/") + related = [] + for r in hn_results + reddit_results: + if r["url"].rstrip("/") == item_url: + continue + related.append(r) + return item.id, related + + tasks = [_search_for_item(item) for item in items] + results = await asyncio.gather(*tasks, return_exceptions=True) + + mapping: Dict[str, List[dict]] = {} + for result in results: + if isinstance(result, Exception): + continue + item_id, related = result + mapping[item_id] = related + return mapping diff --git a/src/services/email.py b/src/services/email.py new file mode 100644 index 0000000..1e8ae4b --- /dev/null +++ b/src/services/email.py @@ -0,0 +1,239 @@ +"""Email service for handling subscriptions and sending summaries.""" + +import email +import html +import imaplib +import logging +import os +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import parseaddr +from typing import List + +try: + import markdown +except ImportError: + markdown = None + +from ..ai.markdown_utils import clean_app_summary_markdown +from ..models import EmailConfig + +logger = logging.getLogger(__name__) + + +class EmailManager: + """Manages email subscriptions and sending summaries.""" + + def __init__(self, config: EmailConfig, console=None): + self.config = config + self.pwd = os.getenv(self.config.password_env) + if console is None: + try: + from rich.console import Console + + self.console = Console() + except ImportError: + + class DummyConsole: + def print(self, *args, **kwargs): + print(*args, **kwargs) + + self.console = DummyConsole() + else: + self.console = console + + if not self.pwd and self.config.enabled: + logger.warning( + f"Environment variable {self.config.password_env} not set. Email features may fail." + ) + self.console.print( + f"[yellow]Warning: Environment variable {self.config.password_env} not set. Email features may fail.[/yellow]" + ) + + def check_subscriptions(self, storage_manager): + """Checks inbox for subscription requests and updates subscriber list.""" + if not self.config.enabled or not self.config.imap_enabled: + return + + try: + mail = imaplib.IMAP4_SSL(self.config.imap_server, self.config.imap_port) + mail.login(self.config.email_address, self.pwd) + mail.select("INBOX") + + keyword = self.config.subscribe_keyword + search_crit = f'(UNSEEN SUBJECT "{keyword}")' + + status, messages = mail.search(None, search_crit) + + if status == "OK" and messages[0]: + email_ids = messages[0].split() + subscribers = storage_manager.load_subscribers() + + for e_id in email_ids: + _, msg_data = mail.fetch(e_id, "(RFC822)") + for response_part in msg_data: + if isinstance(response_part, tuple): + msg = email.message_from_bytes(response_part[1]) + + subject = str(msg.get("Subject") or "").strip() + if subject.upper() != keyword.upper(): + continue + + sender = msg.get("From") + + if sender: + _, email_addr = parseaddr(sender) + if email_addr and "@" in email_addr: + if ( + "noreply" in email_addr.lower() + or "no-reply" in email_addr.lower() + ): + continue + + if email_addr not in subscribers: + storage_manager.add_subscriber(email_addr) + subscribers = storage_manager.load_subscribers() + self._send_reply( + email_addr, + "Subscribed to Horizon", + "You have been successfully subscribed to Horizon daily summaries.", + ) + logger.info(f"Added subscriber: {email_addr}") + else: + logger.info(f"Already subscribed: {email_addr}") + + unsub_keyword = self.config.unsubscribe_keyword + search_crit_unsub = f'(UNSEEN SUBJECT "{unsub_keyword}")' + + status, messages = mail.search(None, search_crit_unsub) + + if status == "OK" and messages[0]: + email_ids = messages[0].split() + subscribers = storage_manager.load_subscribers() + + for e_id in email_ids: + _, msg_data = mail.fetch(e_id, "(RFC822)") + for response_part in msg_data: + if isinstance(response_part, tuple): + msg = email.message_from_bytes(response_part[1]) + + subject = str(msg.get("Subject") or "").strip() + if subject.upper() != unsub_keyword.upper(): + continue + + sender = msg.get("From") + + if sender: + _, email_addr = parseaddr(sender) + if email_addr and "@" in email_addr: + if ( + "noreply" in email_addr.lower() + or "no-reply" in email_addr.lower() + ): + continue + + if email_addr in subscribers: + storage_manager.remove_subscriber(email_addr) + subscribers = storage_manager.load_subscribers() + self._send_reply( + email_addr, + "Unsubscribed from Horizon", + "You have been successfully unsubscribed from Horizon daily summaries.", + ) + logger.info(f"Removed subscriber: {email_addr}") + else: + logger.info(f"Not subscribed: {email_addr}") + + mail.close() + mail.logout() + + except Exception as e: + logger.error(f"Error checking subscriptions: {e}") + + def send_daily_summary(self, summary_md: str, subject: str, subscribers: List[str]): + """Sends the daily summary to all subscribers.""" + if not self.config.enabled or not subscribers: + return + + cleaned_summary = clean_app_summary_markdown(summary_md) + safe_summary = html.escape(cleaned_summary) + html_content = ( + markdown.markdown(safe_summary) + if markdown + else f"
    {safe_summary}
    " + ) + + html_body = f""" + + + + + + + + {html_content} + + + + """ + + try: + with smtplib.SMTP_SSL( + self.config.smtp_server, self.config.smtp_port + ) as server: + server.login( + self.config.smtp_username or self.config.email_address, self.pwd + ) + + for subscriber in subscribers: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = ( + f"{self.config.sender_name} <{self.config.email_address}>" + ) + msg["To"] = subscriber + + text_part = MIMEText(cleaned_summary, "plain") + html_part = MIMEText(html_body, "html") + + msg.attach(text_part) + msg.attach(html_part) + + try: + server.send_message(msg) + logger.info(f"Sent summary to {subscriber}") + except Exception as e: + logger.error(f"Failed to send to {subscriber}: {e}") + + except Exception as e: + logger.error(f"SMTP Error: {e}") + + def _send_reply(self, to_email: str, subject: str, body: str): + """Helper to send a simple reply.""" + try: + with smtplib.SMTP_SSL( + self.config.smtp_server, self.config.smtp_port + ) as server: + server.login( + self.config.smtp_username or self.config.email_address, self.pwd + ) + + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = f"{self.config.sender_name} <{self.config.email_address}>" + msg["To"] = to_email + + server.send_message(msg) + except Exception as e: + logger.error(f"Failed to send reply to {to_email}: {e}") diff --git a/src/services/webhook.py b/src/services/webhook.py new file mode 100644 index 0000000..108ac18 --- /dev/null +++ b/src/services/webhook.py @@ -0,0 +1,777 @@ +"""Webhook notification service for Horizon.""" + +import json +import logging +import os +import re +from urllib.parse import urlsplit, urlunsplit +from datetime import datetime, timezone +from typing import Any, List, Optional, Union, cast +from urllib.parse import urlparse +import httpx + +from ..ai.markdown_utils import clean_app_summary_markdown +from ..models import ContentItem, WebhookConfig +from ..ai.summarizer import DailySummarizer + +logger = logging.getLogger(__name__) + + +# Pattern: #{key} or #{key?param1=val1¶m2=val2} +_PLACEHOLDER_RE = re.compile(r"#\{(\w+)(\?\w+=[^}]+)?\}") +_SENSITIVE_HEADER_RE = re.compile( + r"(authorization|token|secret|signature|key|password)", re.IGNORECASE +) + + +def _truncate(value: str, limit: int, split: str) -> str: + """Truncate a string to at most *limit* characters by splitting on *split*. + + Segments are accumulated in order until adding the next one would + exceed *limit* characters. Remaining segments are dropped. + + Args: + value: The full text to truncate + limit: Maximum number of characters allowed + split: Delimiter to split value into segments + + Returns: + Truncated text + """ + segments = value.split(split) + kept: list[str] = [] + current_chars = 0 + + for seg in segments: + # +len(split) for the delimiter that will be re-joined + seg_chars = len(seg) + (len(split) if kept else 0) + if kept and current_chars + seg_chars > limit: + break + kept.append(seg) + current_chars += seg_chars + + return split.join(kept) + + +def _render( + template: Union[str, dict, list], variables: dict +) -> Union[str, dict, list]: + """Replace #{key} and #{key?params} placeholders in a template. + + Supports strings, dicts, and lists. For dicts/lists, walks all + string values recursively and replaces placeholders. + + Parameterized syntax: #{key?limit=N&split=DELIM} + - limit: maximum number of output characters + - split: delimiter to split the value into segments before + accumulating up to *limit* characters + + Args: + template: Template with #{key} placeholders — str, dict, or list + variables: Dict mapping placeholder keys to replacement values + + Returns: + Same type as template, with placeholders replaced + """ + if isinstance(template, dict): + return {k: _render(v, variables) for k, v in template.items()} + if isinstance(template, list): + return [_render(item, variables) for item in template] + if isinstance(template, str): + + def _replace(match: re.Match) -> str: + key = match.group(1) + params_str = match.group(2) # e.g. "?limit=500&split=---" + + value = variables.get(key) + if value is None: + return match.group(0) # leave placeholder unchanged + + if not params_str: + return str(value) + + # Parse params: ?limit=500&split=--- + raw_params = params_str.lstrip("?") + params: dict[str, str] = {} + for pair in raw_params.split("&"): + if "=" in pair: + k, v = pair.split("=", 1) + params[k] = v + + limit = int(params.get("limit", "0")) if "limit" in params else 0 + split_delim = params.get("split", "---") + + if limit and split_delim: + return _truncate(str(value), limit, split_delim) + + return str(value) + + return _PLACEHOLDER_RE.sub(_replace, template) + # int, float, bool, None — return as-is + return template + + +def _format_markdown_for_webhook(value: str) -> str: + """Flatten HTML constructs that chat/webhook Markdown often cannot render.""" + return clean_app_summary_markdown(value) + + +def _prepare_variables_for_body( + raw_body: Union[str, dict, list, None], variables: dict +) -> dict: + """Apply webhook-safe variable formatting before body rendering.""" + if raw_body is None or "summary" not in variables: + return variables + + prepared = dict(variables) + prepared["summary"] = _format_markdown_for_webhook(str(variables["summary"])) + return prepared + + +def _isjson(s: str) -> bool: + """Return True if the string starts with a JSON open brace.""" + s = s.strip() + return s.startswith("{") or s.startswith("[") + + +def _is_feishu_platform(platform: str) -> bool: + """Return whether platform should use Feishu/Lark card rendering.""" + return platform.lower() in {"feishu", "lark"} + + +def _text(value: str) -> dict[str, str]: + """Build a Feishu plain text object.""" + return {"tag": "plain_text", "content": value} + + +def _markdown(content: str) -> dict[str, str]: + """Build a Feishu Markdown component.""" + return {"tag": "markdown", "content": content} + + +def _collapsible_panel(title: str, content: str) -> dict[str, Any]: + """Build a Feishu Card JSON 2.0 collapsible panel.""" + return { + "tag": "collapsible_panel", + "expanded": False, + "header": { + "title": _text(title), + "icon": { + "tag": "standard_icon", + "token": "down-small-ccm_outlined", + "size": "16px 16px", + }, + "icon_position": "right", + "icon_expanded_angle": -180, + }, + "border": {"color": "grey", "corner_radius": "5px"}, + "elements": [_markdown(content)], + } + + +def _extract_headers(headers_str: Optional[str]) -> dict: + """Parse custom headers from a multi-line "Key: Value" string. + + Args: + headers_str: Multi-line string, each line "Key: Value" + + Returns: + dict: Parsed headers as key-value pairs + """ + if not headers_str: + return {} + + headers = {} + for line in headers_str.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(":", 1) + if len(parts) != 2: + logger.warning("Invalid webhook header line: %s", line) + continue + k, v = parts[0].strip(), parts[1].strip() + headers[k] = v + + return headers + + +def redact_url(url: str) -> str: + """Return a log-safe URL without query strings or fragments.""" + try: + parts = urlsplit(url) + except ValueError: + return "" + if not parts.scheme or not parts.netloc: + return "" + return urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")) + + +def redact_headers(headers: dict[str, str]) -> dict[str, str]: + """Mask sensitive header values for logs and dry-run output.""" + return { + key: "" if _SENSITIVE_HEADER_RE.search(key) else value + for key, value in headers.items() + } + + +class WebhookNotifier: + """Sends webhook notifications after pipeline completion or failure.""" + + def __init__(self, config: WebhookConfig, console=None): + self.config = config + if console is None: + try: + from rich.console import Console + + self.console = Console() + except ImportError: + + class DummyConsole: + def print(self, *args, **kwargs): + print(*args, **kwargs) + + self.console = DummyConsole() + else: + self.console = console + self.url = None + self._validate_config() # sets self.url or raises ValueError + + def _validate_url(self, url: str) -> str: + """Validate webhook URL has a valid scheme (http/https) and hostname. + Raises: + ValueError: If the URL is empty, has wrong scheme, no hostname, + or is structurally invalid + """ + url = url.strip() + # Remove shell escape artifacts: \? \= \& \% before query chars + url = re.sub(r"\\([?=&%])", r"\1", url) + if not url: + raise ValueError( + f"Webhook URL is empty (env var '{self.config.url_env}' is set but empty)" + ) + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"Webhook URL must use http or https scheme, got '{parsed.scheme or 'none'}' " + f"(env var '{self.config.url_env}')" + ) + if not parsed.hostname: + raise ValueError( + f"Webhook URL has no hostname: '{url}' " + f"(env var '{self.config.url_env}')" + ) + try: + httpx.URL(url) + except httpx.InvalidURL as e: + raise ValueError( + f"Webhook URL is structurally invalid: '{url}' — {e} " + f"(env var '{self.config.url_env}')" + ) from e + return url + + def _validate_config(self) -> None: + """Validate webhook URL configuration and print warnings for skip scenarios. + + Raises ValueError when URL is present but invalid. + Sets self.url to the validated URL, or leaves it None for skip scenarios. + """ + if not self.config.url_env: + # url_env not configured at all + logger.warning("Webhook enabled but url_env is not configured, skipping notification.") + self.console.print( + "[yellow]Webhook enabled but 'url_env' is not set in config. " + "No notification URL available, skipping.[/yellow]" + ) + return + + raw_url = os.getenv(self.config.url_env) + if raw_url is None: + # env var name configured, but the env var itself doesn't exist + logger.warning( + "Webhook enabled but env var '%s' is not set, skipping notification.", + self.config.url_env, + ) + self.console.print( + f"[yellow]Webhook enabled but env var '{self.config.url_env}' is not set " + f"in your environment. Skipping notification.[/yellow]" + ) + return + + # env var exists — validate the URL value (strip + scheme + hostname + httpx check) + self.url = self._validate_url(raw_url) + + def _render_request_components( + self, variables: dict + ) -> tuple[str, str | None, dict[str, str]]: + """Render the final request URL, body, and headers for the given variables.""" + request_url = cast(str, _render(self.url or "", variables)) + + content_type = "application/x-www-form-urlencoded" + body_content = None + raw_body = variables.get("_request_body_override", self.config.request_body) + body_variables = _prepare_variables_for_body(raw_body, variables) + + if raw_body: + if isinstance(raw_body, (dict, list)): + rendered_obj = _render(raw_body, body_variables) + body_content = json.dumps(rendered_obj, ensure_ascii=False) + content_type = "application/json" + elif isinstance(raw_body, str) and raw_body.strip(): + rendered = cast(str, _render(raw_body, body_variables)) + body_content = rendered + if _isjson(rendered): + try: + json.loads(rendered) + content_type = "application/json" + except json.JSONDecodeError: + pass + + headers = _extract_headers(self.config.headers) + headers["Content-Type"] = content_type + return request_url, body_content, headers + + def _can_use_feishu_collapsible(self) -> bool: + """Return whether this notifier should render Feishu collapsible cards.""" + platform = getattr(self.config, "platform", "generic") + layout = getattr(self.config, "layout", "markdown") + return _is_feishu_platform(platform) and layout == "collapsible" + + def _build_feishu_collapsible_overview( + self, + item_count: int, + all_items_count: int, + date: str, + lang: str, + ) -> str: + """Build a non-redundant overview for a card that already lists item panels.""" + if lang == "zh": + if item_count == 0: + return ( + f"# Horizon 每日速递 - {date}\n\n" + f"> 已分析 {all_items_count} 条内容,暂无达到重要性阈值的资讯。" + ) + return ( + f"# Horizon 每日速递 - {date}\n\n" + f"> 从 {all_items_count} 条内容中筛选出 {item_count} 条重要资讯。\n\n" + "点击下方新闻面板即可在飞书内展开阅读全文。" + ) + + if item_count == 0: + return ( + f"# Horizon Daily - {date}\n\n" + f"> Analyzed {all_items_count} items, but none met the importance threshold." + ) + + return ( + f"# Horizon Daily - {date}\n\n" + f"> Selected {item_count} important items from {all_items_count} fetched items.\n\n" + "Expand the panels below to read the full briefing inside Feishu/Lark." + ) + + def _build_feishu_collapsible_body( + self, + important_items: List[ContentItem], + all_items_count: int, + date: str, + lang: str, + summarizer: DailySummarizer, + ) -> dict[str, Any]: + """Build a single Feishu Card JSON 2.0 message with collapsed item details.""" + overview = self._build_feishu_collapsible_overview( + item_count=len(important_items), + all_items_count=all_items_count, + date=date, + lang=lang, + ) + elements: list[dict[str, Any]] = [_markdown(overview)] + + for item_index, item in enumerate(important_items, start=1): + title = str(item.metadata.get(f"title_{lang}") or item.title) + score = item.ai_score or "?" + panel_title = f"{item_index}. {title} ⭐️ {score}/10" + item_content = summarizer.generate_webhook_item( + item, + language=lang, + index=item_index, + total=len(important_items), + ) + elements.append( + _collapsible_panel( + panel_title, + _format_markdown_for_webhook(item_content), + ) + ) + + return { + "msg_type": "interactive", + "card": { + "schema": "2.0", + "config": { + "wide_screen_mode": True, + "update_multi": True, + }, + "header": { + "title": { + "tag": "plain_text", + "content": ( + f"Horizon {date} 折叠日报" + if lang == "zh" + else f"Horizon {date} Collapsible Daily" + ), + }, + "template": "blue", + }, + "body": { + "elements": elements, + }, + }, + } + + def build_preview(self, variables: dict) -> dict[str, Any]: + """Build the fully rendered request for dry-run preview.""" + request_url, body_content, headers = self._render_request_components(variables) + return { + "url": redact_url(request_url), + "body": body_content, + "headers": redact_headers(headers), + } + + def build_daily_summary_messages( + self, + summary: str, + important_items: List[ContentItem], + all_items_count: int, + date: str, + lang: str, + summarizer: DailySummarizer, + ) -> List[dict[str, Any]]: + """Build the variables for all webhook messages for one language.""" + webhook_languages = getattr(self.config, "languages", None) + if webhook_languages and lang not in webhook_languages: + return [] + + base_vars = { + "date": date, + "language": lang, + "important_items": len(important_items), + "all_items": all_items_count, + "result": "success", + "timestamp": str(int(datetime.now(timezone.utc).timestamp())), + } + + if self._can_use_feishu_collapsible(): + return [ + { + **base_vars, + "message_title": ( + f"Horizon {date} 折叠日报" + if lang == "zh" + else f"Horizon {date} Collapsible Daily" + ), + "message_kind": "collapsible", + "summary": self._build_feishu_collapsible_overview( + item_count=len(important_items), + all_items_count=all_items_count, + date=date, + lang=lang, + ), + "_request_body_override": self._build_feishu_collapsible_body( + important_items=important_items, + all_items_count=all_items_count, + date=date, + lang=lang, + summarizer=summarizer, + ), + } + ] + + delivery = getattr(self.config, "delivery", "summary") + if delivery == "summary_and_items": + item_messages: List[dict[str, Any]] = [] + overview = summarizer.generate_webhook_overview( + important_items, + date, + all_items_count, + language=lang, + ) + overview_message = { + **base_vars, + "message_title": ( + f"Horizon {date} 总览" + if lang == "zh" + else f"Horizon {date} Overview" + ), + "message_kind": "overview", + "summary": overview, + } + for item_index, item in enumerate(important_items, start=1): + title = str(item.metadata.get(f"title_{lang}") or item.title) + item_summary = summarizer.generate_webhook_item( + item, + language=lang, + index=item_index, + total=len(important_items), + ) + item_messages.append( + { + **base_vars, + "message_title": f"{item_index}/{len(important_items)} {title}", + "message_kind": "item", + "item_index": item_index, + "item_count": len(important_items), + "item_title": title, + "item_url": str(item.url), + "item_score": item.ai_score or "", + "summary": item_summary, + } + ) + + if getattr(self.config, "overview_position", "first") == "last": + return list(reversed(item_messages)) + [overview_message] + + return [overview_message] + item_messages + + return [ + { + **base_vars, + "message_title": ( + f"Horizon {date} 日报" if lang == "zh" else f"Horizon {date} Daily" + ), + "message_kind": "summary", + "summary": summary, + } + ] + + async def notify(self, variables: dict) -> None: + """Send a webhook notification with template variable substitution. + + If request_body is empty, sends a GET request. + If request_body is provided, sends a POST request with + auto-detected content-type + + Args: + variables: Dict of template variable values to replace + in URL, request_body, and headers. + """ + if not self.config.enabled: + self.console.print("[yellow]Webhook is disabled, skipping notification.[/yellow]") + return + + if not self.url: + logger.warning( + "Webhook enabled but URL is empty (env var %s not set), skipping notification.", + self.config.url_env, + ) + self.console.print( + f"[yellow]Webhook enabled but URL is empty — " + f"env var '{self.config.url_env}' is not set. Skipping notification.[/yellow]" + ) + return + + request_url, body_content, headers = self._render_request_components(variables) + safe_url = redact_url(request_url) + if body_content is not None: + logger.debug( + "Webhook POST body (%d chars): %s", + len(body_content or ""), + (body_content or "")[:2000], + ) + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + if body_content is None: + response = await client.get(request_url, headers=headers) + else: + response = await client.post( + request_url, + content=body_content.encode("utf-8"), + headers=headers, + ) + + self._handle_response_status(response, safe_url) + + except httpx.InvalidURL as e: + self.console.print( + f"[red]Webhook URL is invalid: {e}[/red]" + ) + logger.error("Webhook URL invalid: %s, env var: %s", e, self.config.url_env) + except httpx.ConnectError as e: + self.console.print( + f"[red]Webhook connection failed: {e}[/red]" + ) + logger.error("Webhook connection failed: URL=%s, error=%s", safe_url, e) + except httpx.TimeoutException as e: + self.console.print( + f"[red]Webhook request timed out: {e}[/red]" + ) + logger.error("Webhook timeout: URL=%s, error=%s", safe_url, e) + except Exception as e: + self.console.print( + f"[red]Webhook call failed unexpectedly: {type(e).__name__}: {e}[/red]" + ) + logger.error("Webhook unexpected error: URL=%s, type=%s, error=%s", safe_url, type(e).__name__, e) + + def _check_body_error_code(self, body: str) -> Optional[str]: + """Check if a 2xx response body contains a platform-specific error code. + + Returns a descriptive string if an error is detected, or None if the + response appears successful. + + Checked patterns: + - Feishu/Lark: {"code": non-zero, "msg": "..."} or {"StatusCode": non-zero} + - DingTalk: {"errcode": non-zero, "errmsg": "..."} + - Slack/Discord: {"ok": false} + """ + try: + data = json.loads(body) + except (json.JSONDecodeError, ValueError): + return None + + platform = (self.config.platform or "").lower() + check_all = platform in ("", "generic") + + if platform in ("feishu", "lark") or check_all: + code = data.get("code") or data.get("StatusCode") + if code is not None and code != 0: + msg = data.get("msg") or data.get("StatusMessage") or "" + return f"Feishu/Lark error (code={code}): {msg}" + + if platform == "dingtalk" or check_all: + errcode = data.get("errcode") + if errcode is not None and errcode != 0: + msg = data.get("errmsg") or "" + return f"DingTalk error (errcode={errcode}): {msg}" + + if platform in ("slack", "discord") or check_all: + if data.get("ok") is False: + error = data.get("error") or "" + return f"Slack/Discord error: {error}" + + return None + + def _handle_response_status(self, response: httpx.Response, safe_url: str) -> None: + """Log and display HTTP response status by category. + + Even 2xx responses may contain platform-specific error codes + in the JSON body (e.g. Feishu code=19001, DingTalk errcode=400, + Slack ok=false). + """ + status = response.status_code + body = response.text[:500] + + if 200 <= status < 300: + error_hint = self._check_body_error_code(body) + if error_hint: + logger.warning( + "Webhook 2xx but body contains error: URL=%s, status=%d, body=%s", + safe_url, status, body, + ) + self.console.print( + f"[yellow]Webhook response (status={status}): {body}[/yellow]\n" + f"[yellow]{error_hint}[/yellow]" + ) + else: + logger.info("Webhook sent OK. URL: %s, body: %s", safe_url, body) + self.console.print( + f"[green]Webhook response (status={status}): {body}[/green]" + ) + return + + if 300 <= status < 400: + location = response.headers.get("location", "") + self.console.print( + f"[yellow]Webhook received redirect (status={status})[/yellow]" + ) + logger.warning( + "Webhook redirect: URL=%s, status=%d, location=%s", + safe_url, status, location, + ) + elif 400 <= status < 500: + self.console.print( + f"[red]Webhook client error (status={status}): {response.text[:500]}[/red]" + ) + logger.error( + "Webhook client error: URL=%s, status=%d, body=%s", + safe_url, status, response.text[:500], + ) + elif 500 <= status < 600: + self.console.print( + f"[red]Webhook server error (status={status}): {response.text[:500]}[/red]" + ) + logger.error( + "Webhook server error: URL=%s, status=%d, body=%s", + safe_url, status, response.text[:500], + ) + else: + self.console.print( + f"[red]Webhook unexpected status={status}: {response.text[:500]}[/red]" + ) + logger.error("Webhook unexpected status: URL=%s, status=%d", safe_url, status) + + async def send_daily_summary( + self, + summary: str, + important_items: List[ContentItem], + all_items_count: int, + date: str, + lang: str, + summarizer: DailySummarizer, + ) -> None: + """Send daily summary webhook notification. + + Handles language filtering, delivery mode (summary vs summary_and_items), + and variable construction internally. + + Args: + summary: Full markdown summary text + important_items: List of important content items + all_items_count: Total number of items fetched + date: Date string (YYYY-MM-DD) + lang: Language code ("en" or "zh") + summarizer: DailySummarizer instance for generating webhook overviews + """ + messages = self.build_daily_summary_messages( + summary=summary, + important_items=important_items, + all_items_count=all_items_count, + date=date, + lang=lang, + summarizer=summarizer, + ) + if not messages: + self.console.print( + f"🔕 Skipping {lang.upper()} webhook notification " + f"(filtered by webhook.languages)" + ) + return + + self.console.print(f"🔔 Sending {lang.upper()} webhook notification...") + for message in messages: + await self.notify(message) + + async def send_failure( + self, + date: str, + error_message: str, + ) -> None: + """Send webhook notification when the pipeline fails. + + Args: + date: Date string (YYYY-MM-DD) + error_message: Description of the failure + """ + self.console.print("🔔 Sending webhook failure notification...") + await self.notify( + { + "date": date, + "language": "", + "important_items": 0, + "all_items": 0, + "result": "failed", + "timestamp": str(int(datetime.now(timezone.utc).timestamp())), + "message_title": "Horizon generation failed", + "message_kind": "failure", + "summary": f"generation failed: {error_message}", + } + ) diff --git a/src/services/webhook_cli.py b/src/services/webhook_cli.py new file mode 100644 index 0000000..2b70413 --- /dev/null +++ b/src/services/webhook_cli.py @@ -0,0 +1,218 @@ +"""CLI entry point for testing webhook connectivity.""" + +import argparse +import asyncio +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +from dotenv import load_dotenv +from rich.console import Console +from rich.panel import Panel + +from ..ai.summarizer import DailySummarizer +from ..models import ContentItem, SourceType +from ..storage.manager import ConfigError, StorageManager +from .webhook import WebhookNotifier + +console = Console() + + +def _make_test_items() -> list[ContentItem]: + """Create sample ContentItems for the test notification.""" + return [ + ContentItem( + id="github:test:1", + source_type=SourceType.GITHUB, + title="GPT-5 Released with Multimodal Capabilities", + url="https://example.com/gpt5", + content="OpenAI announced GPT-5 with major improvements.", + author="openai", + published_at=datetime(2026, 4, 24, 10, 0, tzinfo=timezone.utc), + fetched_at=datetime(2026, 4, 24, 12, 0, tzinfo=timezone.utc), + ai_score=9.0, + ai_summary="OpenAI released GPT-5 featuring multimodal capabilities and improved reasoning.", + ai_tags=["ai", "llm", "openai"], + metadata={ + "title_zh": "GPT-5 发布:多模态能力大幅提升", + "detailed_summary_zh": "OpenAI 发布了 GPT-5,具备多模态能力和更强的推理能力。", + }, + ), + ContentItem( + id="hackernews:test:2", + source_type=SourceType.HACKERNEWS, + title="New Linux Kernel 7.0 Released", + url="https://example.com/linux7", + content="Linux kernel 7.0 brings significant performance improvements.", + author="torvalds", + published_at=datetime(2026, 4, 24, 8, 0, tzinfo=timezone.utc), + fetched_at=datetime(2026, 4, 24, 12, 0, tzinfo=timezone.utc), + ai_score=7.5, + ai_summary="Linux kernel 7.0 released with performance gains and new hardware support.", + ai_tags=["linux", "kernel", "performance"], + metadata={ + "title_zh": "Linux 内核 7.0 发布", + "detailed_summary_zh": "Linux 内核 7.0 发布,带来显著性能提升和新硬件支持。", + }, + ), + ] + + +def _preview_message( + notifier: WebhookNotifier, title: str, body: str, variables: dict, border_style: str +) -> None: + """Render one dry-run preview using the same logic as the real sender.""" + display_body = body if len(body) <= 3000 else body[:3000] + "\n... (truncated)" + console.print(Panel(display_body, title=title, border_style=border_style)) + preview = notifier.build_preview(variables) + console.print("\n[bold]── Variable Rendering Preview ──[/bold]") + console.print(f" [cyan]URL:[/cyan] {preview['url']}") + if preview["body"] is not None: + rendered_body = preview["body"] + if len(rendered_body) > 3000: + rendered_body = rendered_body[:3000] + "\n... (truncated)" + panel_title = ( + "Request Body (JSON)" + if preview["headers"]["Content-Type"] == "application/json" + else "Request Body" + ) + console.print(Panel(rendered_body, title=panel_title, border_style="green")) + if preview["headers"]: + console.print( + f" [cyan]Headers:[/cyan] {json.dumps(preview['headers'], ensure_ascii=False)}" + ) + + +async def _run_test( + webhook_config, lang: str, dry_run: bool, delivery_override: str | None = None +) -> None: + """Execute the webhook test.""" + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + items = _make_test_items() + summarizer = DailySummarizer() + summary = await summarizer.generate_summary(items, today, len(items), language=lang) + + effective_config = webhook_config + if delivery_override: + effective_config = webhook_config.model_copy( + update={"delivery": delivery_override} + ) + + notifier = WebhookNotifier(effective_config, console=console) + + if dry_run: + console.print(f"\n[bold yellow]── Dry Run (lang={lang}) ──[/bold yellow]") + console.print(f" [cyan]Webhook enabled:[/cyan] {effective_config.enabled}") + console.print(f" [cyan]URL env var:[/cyan] {effective_config.url_env}") + console.print(f" [cyan]Delivery mode:[/cyan] {effective_config.delivery}") + console.print(f" [cyan]Platform:[/cyan] {effective_config.platform}") + console.print(f" [cyan]Layout:[/cyan] {effective_config.layout}") + + messages = notifier.build_daily_summary_messages( + summary=summary, + important_items=items, + all_items_count=len(items), + date=today, + lang=lang, + summarizer=summarizer, + ) + if not messages: + console.print( + f" [yellow]Language '{lang}' is filtered out by " + f"webhook.languages={effective_config.languages}. " + f"Notification would be skipped.[/yellow]" + ) + return + + for index, message in enumerate(messages, start=1): + label = ( + "Message" + if message["message_kind"] == "summary" + else f"Message {index}" + ) + console.print(f"\n[bold]── {label}: {message['message_kind']} ──[/bold]") + _preview_message( + notifier=notifier, + title=message["message_title"], + body=message["summary"], + variables=message, + border_style="blue" if message["message_kind"] != "item" else "green", + ) + + console.print("\n[green]Dry run complete. No notification was sent.[/green]") + return + + console.print(f"\n[bold]── Sending Test Notification (lang={lang}) ──[/bold]") + await notifier.send_daily_summary( + summary=summary, + important_items=items, + all_items_count=len(items), + date=today, + lang=lang, + summarizer=summarizer, + ) + + +def main() -> None: + """CLI entry point for horizon-webhook.""" + parser = argparse.ArgumentParser( + description="Test webhook connectivity and preview rendered content", + ) + parser.add_argument( + "--lang", + default=None, + help="Language to test (en or zh). Defaults to the first language in config.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview the rendered content without actually sending the notification.", + ) + parser.add_argument( + "--delivery", + default=None, + choices=["summary", "summary_and_items"], + help="Override the delivery mode from config for this test.", + ) + args = parser.parse_args() + + try: + load_dotenv() + + storage = StorageManager(data_dir=str(Path("data"))) + try: + config = storage.load_config() + except FileNotFoundError: + console.print("[bold red]Configuration file not found![/bold red]") + console.print( + "Run [bold cyan]uv run horizon-wizard[/bold cyan] to set up your configuration." + ) + sys.exit(1) + except ConfigError as e: + console.print(f"[bold red]Error loading configuration: {e}[/bold red]") + sys.exit(1) + + if not config.webhook or not config.webhook.enabled: + console.print("[yellow]Webhook is not enabled in config.json.[/yellow]") + console.print( + "Set [cyan]webhook.enabled = true[/cyan] in data/config.json to enable it." + ) + sys.exit(1) + + lang = args.lang or (config.ai.languages[0] if config.ai.languages else "en") + asyncio.run(_run_test(config.webhook, lang, args.dry_run, args.delivery)) + + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted by user[/yellow]") + sys.exit(0) + except Exception as e: + console.print(f"\n[bold red]Error: {e}[/bold red]") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/setup/__init__.py b/src/setup/__init__.py new file mode 100644 index 0000000..b02cb29 --- /dev/null +++ b/src/setup/__init__.py @@ -0,0 +1 @@ +"""Setup wizard for Horizon configuration.""" diff --git a/src/setup/ai_recommend.py b/src/setup/ai_recommend.py new file mode 100644 index 0000000..786ecdf --- /dev/null +++ b/src/setup/ai_recommend.py @@ -0,0 +1,70 @@ +"""AI-powered source recommendation for the setup wizard.""" + +import asyncio +from typing import List, Dict, Optional + +from ..ai.client import create_ai_client +from ..ai.utils import parse_json_response +from ..models import AIConfig +from .prompts import RECOMMEND_SYSTEM, RECOMMEND_USER + + +async def get_ai_recommendations( + ai_config: AIConfig, + interests: str, + existing_sources: List[Dict], +) -> List[Dict]: + """Ask AI to recommend additional sources beyond presets. + + Args: + ai_config: AI configuration for creating the client. + interests: User's interest description. + existing_sources: Already-selected sources (for dedup context). + + Returns: + List of recommended source dicts with origin="ai". + """ + try: + client = create_ai_client(ai_config) + except (ValueError, Exception): + return [] + + # Format existing sources for the prompt + existing_lines = [] + for src in existing_sources: + desc = src.get("description", src.get("type", "unknown")) + existing_lines.append(f" - [{src.get('type', '?')}] {desc}") + existing_str = "\n".join(existing_lines) if existing_lines else " (none)" + + user_prompt = RECOMMEND_USER.format( + interests=interests, + existing_sources=existing_str, + ) + + try: + response = await client.complete( + system=RECOMMEND_SYSTEM, + user=user_prompt, + ) + except Exception: + return [] + + result = parse_json_response(response) + if result is None: + return [] + + sources = result.get("sources", []) + # Tag each source as AI-recommended + for src in sources: + src["origin"] = "ai" + + return sources + + +def get_ai_recommendations_sync( + ai_config: AIConfig, + interests: str, + existing_sources: List[Dict], +) -> List[Dict]: + """Synchronous wrapper for get_ai_recommendations.""" + return asyncio.run(get_ai_recommendations(ai_config, interests, existing_sources)) diff --git a/src/setup/presets.py b/src/setup/presets.py new file mode 100644 index 0000000..c068e0c --- /dev/null +++ b/src/setup/presets.py @@ -0,0 +1,299 @@ +"""Preset source library loader and keyword matching. + +Supports loading from the horizon-site API (preferred) or local file (fallback). +API data is served in horizon-site's own format (Category/SourceType enums) +and transformed internally to the preset format that match_domains() expects. +""" + +import json +import os +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import httpx + +from .tag_aliases import get_tag_aliases + + +API_BASE_URL = os.environ.get( + "HORIZON_API_URL", "https://horizon1123.top" +) +PRESETS_ENDPOINT = f"{API_BASE_URL}/api/presets" +REQUEST_TIMEOUT = 10 # seconds + + +def fetch_presets() -> Optional[Dict]: + """Fetch presets from the horizon-site API. + + Returns: + Dict in the internal preset format (with "domains" key), + or None if the fetch fails. + """ + try: + response = httpx.get( + PRESETS_ENDPOINT, + timeout=REQUEST_TIMEOUT, + follow_redirects=True, + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + data = response.json() + + if "categories" not in data: + return None + + return _transform_api_response(data) + except (httpx.HTTPError, json.JSONDecodeError, ValueError): + return None + + +def _transform_api_response(api_data: Dict) -> Dict: + """Transform horizon-site API response to the internal preset format. + + The API returns data in horizon-site's format (Category enum IDs like + "AI_ML", SourceType enums like "REDDIT"). This function converts it + to the preset format with kebab-case IDs and lowercase type strings + that match_domains() and collect_sources_from_domains() expect. + + Args: + api_data: Raw API response with "categories" key. + + Returns: + Dict with "domains" key in preset format. + """ + domains = [] + for category in api_data.get("categories", []): + sources = [] + for src in category.get("sources", []): + config = dict(src.get("config", {})) + + # Horizon-site stores "name" at the source level, but + # horizon's build_config() expects it inside config for RSS sources. + src_type = src.get("type", "rss") + source_name = src.get("name", "") + if src_type == "rss" and source_name and "name" not in config: + config["name"] = source_name + + # Remove the internal "subtype" field that horizon-site uses + # for GitHub sources — horizon uses the type field instead. + if src_type in ("github_user", "github_repo"): + config.pop("subtype", None) + + sources.append({ + "type": src_type, + "description": src.get("description", ""), + "description_zh": src.get("description_zh", src.get("description", "")), + "tags": src.get("tags", []), + "config": config, + }) + + domains.append({ + "id": category.get("id", "").lower().replace("_", "-"), + "name": category.get("name", ""), + "name_zh": category.get("name_zh", category.get("name", "")), + "keywords": category.get("keywords", []), + "sources": sources, + }) + + return {"domains": domains} + + +def load_presets( + presets_path: str = "data/presets.json", + prefer_api: bool = True, +) -> Dict: + """Load presets from API (preferred) or local file (fallback). + + Args: + presets_path: Path to the local presets JSON file for fallback. + prefer_api: Whether to try the API first. Set False for offline mode + or when HORIZON_OFFLINE environment variable is set. + + Returns: + Dict with "domains" key containing preset data. + + Raises: + FileNotFoundError: If both API and local file are unavailable. + """ + offline = os.environ.get("HORIZON_OFFLINE", "").lower() in ("1", "true", "yes") + + if prefer_api and not offline: + api_presets = fetch_presets() + if api_presets is not None: + return api_presets + + path = Path(presets_path) + if not path.exists(): + raise FileNotFoundError( + f"Presets file not found: {path}\n" + f"API fetch also failed. Please check your connection " + f"or ensure {presets_path} exists." + ) + + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def match_domains( + user_input: str, + presets: Dict, + threshold: float = 0.1, +) -> List[Tuple[Dict, float]]: + """Match user interest description against preset domains. + + Performs case-insensitive keyword matching against domain keywords and + source tags. Returns matched domains sorted by relevance score. + + Args: + user_input: Free-form user interest description (supports mixed languages). + presets: Loaded presets dictionary. + threshold: Minimum score (0–1) to include a domain. + + Returns: + List of (domain_dict, score) tuples sorted by descending score. + """ + tokens = set(user_input.lower().split()) + input_lower = user_input.lower() + + results = [] + for domain in presets.get("domains", []): + score = 0.0 + domain_keywords = [k.lower() for k in domain.get("keywords", [])] + total_keywords = len(domain_keywords) or 1 + + for kw in domain_keywords: + if kw in tokens or kw in input_lower: + score += 1.0 + + for source in domain.get("sources", []): + for tag in source.get("tags", []): + if tag.lower() in tokens or tag.lower() in input_lower: + score += 0.3 + + normalized = min(score / total_keywords, 1.0) + if normalized >= threshold: + results.append((domain, normalized)) + + results.sort(key=lambda x: x[1], reverse=True) + return results + + +def collect_sources_from_domains( + matched_domains: List[Tuple[Dict, float]], +) -> List[Dict]: + """Flatten matched domains into a deduplicated list of source configs. + + Args: + matched_domains: Output from match_domains(). + + Returns: + List of source dicts (each with type, description, config, origin="preset"). + """ + seen = set() + sources = [] + + for domain, _score in matched_domains: + for src in domain.get("sources", []): + key = _source_unique_key(src) + if key not in seen: + seen.add(key) + sources.append({**src, "origin": "preset"}) + + return sources + + +def _tag_matches_input(tag: str, tokens: set, input_lower: str) -> bool: + """Check if a tag (or any of its aliases) matches the user input.""" + tag_lower = tag.lower() + if tag_lower in tokens or tag_lower in input_lower: + return True + for alias in get_tag_aliases(tag): + alias_lower = alias.lower() + if alias_lower in tokens or alias_lower in input_lower: + return True + return False + + +def match_sources( + user_input: str, + presets: Dict, + threshold: float = 0.1, +) -> List[Tuple[Dict, float]]: + """Match user interest description against individual sources. + + Scores each source based on: + - Category keyword matches (+1.0 per keyword) + - Source tag matches (+0.5 per tag) + - Description word matches (+0.3 per word, min length 3) + + Args: + user_input: Free-form user interest description (supports mixed languages). + presets: Loaded presets dictionary. + threshold: Minimum normalized score to include a source. + + Returns: + List of (source_dict, score) tuples sorted by descending score. + Each source_dict has origin="preset" added. + """ + tokens = set(user_input.lower().split()) + input_lower = user_input.lower() + total_tokens = len(tokens) or 1 + + seen = set() + results = [] + + for domain in presets.get("domains", []): + domain_keywords = [k.lower() for k in domain.get("keywords", [])] + + category_score = sum( + 1.0 for kw in domain_keywords + if kw in tokens or kw in input_lower + ) + + for src in domain.get("sources", []): + key = _source_unique_key(src) + if key in seen: + continue + seen.add(key) + + tag_score = sum( + 0.5 for tag in src.get("tags", []) + if _tag_matches_input(tag, tokens, input_lower) + ) + + description = src.get("description", "").lower() + desc_tokens = set(description.split()) + desc_score = sum( + 0.3 for token in tokens + if len(token) >= 3 and token in desc_tokens + ) + + raw_score = category_score + tag_score + desc_score + normalized = min(raw_score / total_tokens, 1.0) + + if normalized >= threshold: + results.append(({**src, "origin": "preset"}, normalized)) + + results.sort(key=lambda x: x[1], reverse=True) + return results + + +def _source_unique_key(source: Dict) -> str: + """Generate a unique key for a source to enable deduplication.""" + src_type = source.get("type", "") + cfg = source.get("config", {}) + + if src_type == "rss": + return f"rss:{cfg.get('url', '')}" + elif src_type == "reddit_subreddit": + return f"reddit:{cfg.get('subreddit', '')}" + elif src_type == "reddit_user": + return f"reddit_user:{cfg.get('username', '')}" + elif src_type == "github_user": + return f"github_user:{cfg.get('username', '')}" + elif src_type == "github_repo": + return f"github_repo:{cfg.get('owner', '')}/{cfg.get('repo', '')}" + elif src_type == "telegram": + return f"telegram:{cfg.get('channel', '')}" + else: + return f"{src_type}:{json.dumps(cfg, sort_keys=True)}" \ No newline at end of file diff --git a/src/setup/prompts.py b/src/setup/prompts.py new file mode 100644 index 0000000..e0f743d --- /dev/null +++ b/src/setup/prompts.py @@ -0,0 +1,41 @@ +"""AI recommendation prompts for the setup wizard.""" + +RECOMMEND_SYSTEM = """\ +You are a technical information source recommendation expert. You help users \ +discover RSS feeds, GitHub repositories, Reddit communities, and Telegram channels \ +that match their interests. + +You should recommend sources that are: +- Actively maintained and regularly updated +- High signal-to-noise ratio +- Authoritative in their domain + +Respond ONLY with a JSON object. No explanation outside the JSON.""" + +RECOMMEND_USER = """\ +The user is interested in: {interests} + +They already have these sources configured: +{existing_sources} + +Please recommend 3-8 ADDITIONAL sources that are NOT already in their list. \ +Focus on niche, high-quality sources the user might not know about. + +Return a JSON object with this structure: +{{ + "sources": [ + {{ + "type": "rss" | "reddit_subreddit" | "github_user" | "github_repo" | "telegram", + "description": "Brief English description", + "description_zh": "简短中文描述", + "reason": "Why this source is relevant", + "config": {{ + // For rss: {{"name": "...", "url": "...", "category": "..."}} + // For reddit_subreddit: {{"subreddit": "...", "sort": "hot", "fetch_limit": 15, "min_score": 50}} + // For github_user: {{"username": "..."}} + // For github_repo: {{"owner": "...", "repo": "..."}} + // For telegram: {{"channel": "...", "fetch_limit": 20}} + }} + }} + ] +}}""" diff --git a/src/setup/tag_aliases.py b/src/setup/tag_aliases.py new file mode 100644 index 0000000..35c64eb --- /dev/null +++ b/src/setup/tag_aliases.py @@ -0,0 +1,120 @@ +"""Tag alias lookup for multilingual matching in setup wizard. + +Mirrors horizon-site/app/lib/tagAliases.ts — canonical tag -> aliases +(including Chinese, abbreviations, and common variations). +""" + +TAG_ALIASES: dict[str, list[str]] = { + "ai": ["人工智能", "AI", "artificial-intelligence"], + "aigc": ["生成式AI", "生成式人工智能", "generative-ai"], + "algorithms": ["算法", "Algorithms"], + "analysis": ["分析", "Analysis"], + "analytics": ["数据分析", "Analytics"], + "android": ["Android", "安卓"], + "angular": ["Angular"], + "aws": ["AWS", "Amazon Web Services", "亚马逊云"], + "azure": ["Azure", "微软云"], + "backend": ["后端", "Backend"], + "bigdata": ["大数据", "big-data", "Big Data"], + "blockchain": ["区块链", "Blockchain", "Web3"], + "c": ["C语言", "C"], + "chinese": ["中文", "Chinese"], + "cicd": ["CI/CD", "持续集成", "持续部署"], + "cli": ["命令行", "CLI", "command-line"], + "cpp": ["C++", "c++"], + "cryptography": ["密码学", "加密"], + "css": ["CSS", "样式"], + "cv": ["计算机视觉", "CV", "computer-vision"], + "dataeng": ["数据工程", "data-engineering", "Data Engineering"], + "datascience": ["数据科学", "data-science", "Data Science"], + "datastructure": ["数据结构", "data-structures", "Data Structures"], + "defi": ["DeFi", "去中心化金融"], + "designpattern": ["设计模式", "design-patterns", "Design Patterns"], + "devops": ["DevOps"], + "distributed": ["分布式系统", "distributed-systems"], + "django": ["Django"], + "dl": ["深度学习", "deep-learning", "神经网络"], + "docker": ["Docker", "docker", "容器"], + "editor": ["编辑器", "Editor"], + "embedded": ["嵌入式", "Embedded"], + "embodied": ["具身智能", "Embodied Intelligence"], + "es": ["Elasticsearch", "elasticsearch", "搜索引擎"], + "ethereum": ["以太坊", "Ethereum"], + "flutter": ["Flutter"], + "frontend": ["前端", "Frontend"], + "gcp": ["GCP", "Google Cloud", "谷歌云"], + "golang": ["Go", "Golang", "go语言"], + "hardware": ["硬件", "Hardware"], + "html": ["HTML"], + "ios": ["iOS", "苹果开发"], + "java": ["Java", "java语言"], + "javascript": ["JavaScript", "JS", "js", "ECMAScript"], + "k8s": ["Kubernetes", "kubernetes", "容器编排"], + "kernel": ["内核", "Kernel"], + "kotlin": ["Kotlin"], + "linux": ["Linux", "linux"], + "llm": ["大语言模型", "LLM", "large-language-model", "大模型"], + "llm-inference": ["大模型推理", "LLM Inference", "llm-inference", "推理"], + "maker": [], + "microservice": ["微服务", "microservices", "Microservices"], + "ml": ["机器学习", "machine-learning"], + "mongo": ["MongoDB", "mongodb"], + "mysql": ["MySQL", "mysql"], + "neovim": ["Neovim", "neovim"], + "news": ["新闻"], + "nextjs": ["Next.js", "NextJS"], + "nft": ["NFT", "非同质化代币"], + "nlp": ["自然语言处理", "NLP", "natural-language-processing"], + "nodejs": ["Node.js", "NodeJS", "node"], + "os": ["操作系统", "operating-system"], + "performance": ["性能优化", "Performance"], + "php": ["PHP", "php语言"], + "pl": ["编程语言", "programming-languages", "Programming Languages", "语言"], + "postgres": ["PostgreSQL", "Postgres", "PG", "pg", "postgresql"], + "python": ["Python", "python编程", "py"], + "react": ["React", "ReactJS", "react.js"], + "redis": ["Redis", "缓存"], + "research": ["研究", "Research"], + "rl": ["强化学习", "reinforcement-learning"], + "rn": ["React Native", "react-native"], + "robotics": [], + "ruby": ["Ruby", "ruby语言"], + "rust": ["Rust", "rust语言", "rust编程"], + "science": ["科学", "Science"], + "security": ["安全", "网络安全", "Security"], + "serverless": ["无服务器", "Serverless"], + "sglang": ["SGLang"], + "smart-contract": ["智能合约", "smart-contracts", "Smart Contracts"], + "spring": ["Spring", "Spring Boot"], + "svelte": ["Svelte"], + "swift": ["Swift"], + "systems": ["系统"], + "terminal": ["终端", "Terminal"], + "terraform": ["Terraform", "IaC"], + "testing": ["测试", "Testing"], + "theory": ["理论", "Theory"], + "tools": ["工具", "Tools"], + "trending": ["趋势", "Trending"], + "typescript": ["TypeScript", "TS", "ts"], + "vue": ["Vue", "VueJS", "vue.js", "vue框架"], + "zig": [], +} + +# Reverse lookup: alias (lowercased) -> canonical tag +_REVERSE_MAP: dict[str, str] = {} +for _main, _aliases in TAG_ALIASES.items(): + _REVERSE_MAP[_main.lower()] = _main + for _alias in _aliases: + _REVERSE_MAP[_alias.lower()] = _main + + +def get_tag_aliases(tag: str) -> list[str]: + """Return all aliases for a canonical tag (empty list if unknown).""" + return TAG_ALIASES.get(tag.lower(), []) + + +def resolve_tag_alias(input_str: str) -> str: + """Resolve a tag or alias to its canonical form (lowercased).""" + normalized = input_str.lower().strip() + resolved = _REVERSE_MAP.get(normalized) + return resolved if resolved else normalized diff --git a/src/setup/wizard.py b/src/setup/wizard.py new file mode 100644 index 0000000..61d7374 --- /dev/null +++ b/src/setup/wizard.py @@ -0,0 +1,454 @@ +"""Interactive setup wizard for Horizon configuration.""" + +import json +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional + +from dotenv import load_dotenv +from rich.console import Console +from rich.prompt import Prompt, Confirm +from rich.table import Table +from rich.panel import Panel + +from ..models import ( + AIConfig, AIProvider, AI_PROVIDER_DEFAULTS, Config, FilteringConfig, SourcesConfig, + GitHubSourceConfig, HackerNewsConfig, RSSSourceConfig, + RedditConfig, RedditSubredditConfig, RedditUserConfig, + TelegramConfig, TelegramChannelConfig, +) +from ..storage.manager import StorageManager +from .presets import load_presets, match_sources + + +console = Console() + + +def print_banner(): + """Print the setup wizard banner.""" + banner = r""" +[bold blue] + _ _ _ + | | | | (_) + | |__| | ___ _ __ _ ___ ___ _ __ + | __ |/ _ \| '__| |_ / / _ \| '_ \ + | | | | (_) | | | |/ / | (_) | | | | + |_| |_|\___/|_| |_/___| \___/|_| |_| +[/bold blue] +[cyan] Setup Wizard — Configure your information sources[/cyan] + """ + console.print(banner) + + +def configure_ai() -> Optional[AIConfig]: + """Step 1: Configure AI provider. + + Returns: + AIConfig if configured, None if user skips. + """ + console.print("\n[bold]Step 1: AI Configuration[/bold]\n") + + # Check for existing .env + load_dotenv() + + providers = [p.value for p in AIProvider] + console.print(f"Available providers: {', '.join(providers)}") + provider = Prompt.ask( + "AI provider", + choices=providers, + default="openai", + ) + provider_enum = AIProvider(provider) + + provider_defaults = AI_PROVIDER_DEFAULTS.get(provider_enum, {}) + model = Prompt.ask("Model name", default=provider_defaults.get("model", "")) + + if provider_enum == AIProvider.OLLAMA: + base_url = Prompt.ask( + "Ollama base URL (leave empty for http://localhost:11434)", + default="", + ) + else: + base_url = Prompt.ask("Base URL (leave empty for default)", default="") + + # Determine default env var name + api_key_env = Prompt.ask( + "API key environment variable name", + default=provider_defaults.get("api_key_env", "API_KEY"), + ) + + # Check if the key is actually set + if api_key_env and not os.getenv(api_key_env): + console.print( + f"[yellow]⚠ {api_key_env} is not set in environment or .env file.[/yellow]" + ) + console.print(" AI features (smart recommendations) will be skipped.") + console.print(f" Add it to your .env file later: {api_key_env}=your_key_here\n") + + languages = Prompt.ask( + "Output languages (comma-separated)", + default="zh,en", + ) + lang_list = [l.strip() for l in languages.split(",") if l.strip()] + + return AIConfig( + provider=provider_enum, + model=model, + base_url=base_url or None, + api_key_env=api_key_env, + temperature=0.3, + max_tokens=8192, + languages=lang_list, + ) + + +def _ai_recommendations_available(ai_config: AIConfig) -> bool: + if ai_config.provider == AIProvider.OLLAMA: + return True + return bool(ai_config.api_key_env and os.getenv(ai_config.api_key_env)) + + +def get_interests() -> str: + """Step 2: Get user's interest description. + + Returns: + Free-form interest string. + """ + console.print("\n[bold]Step 2: Describe Your Interests[/bold]\n") + console.print( + "Describe what topics you'd like to follow. " + "You can use Chinese, English, or both.\n" + "[dim]Examples: \"LLM inference\", \"具身智能\", \"Rust systems programming\", " + "\"web security\", \"开源工具\"[/dim]\n" + ) + interests = Prompt.ask("Your interests") + return interests + + +def select_sources( + preset_sources: List[Dict], + ai_sources: List[Dict], +) -> List[Dict]: + """Step 5: Interactive source selection with a rich table. + + Args: + preset_sources: Sources matched from presets. + ai_sources: Sources recommended by AI. + + Returns: + List of selected source dicts. + """ + all_sources = preset_sources + ai_sources + if not all_sources: + console.print("[yellow]No sources matched your interests.[/yellow]") + return [] + + # Display the recommendation table + console.print("\n[bold]Step 3: Review Recommended Sources[/bold]\n") + + table = Table(show_header=True, header_style="bold") + table.add_column("#", justify="right", style="dim", width=3) + table.add_column("Type", width=12) + table.add_column("Description", min_width=30) + table.add_column("Origin", width=8) + table.add_column("Enabled", justify="center", width=7) + + enabled = [True] * len(all_sources) + + for i, src in enumerate(all_sources): + origin_style = "green" if src.get("origin") == "preset" else "cyan" + table.add_row( + str(i + 1), + src.get("type", "?"), + src.get("description", ""), + f"[{origin_style}]{src.get('origin', '?')}[/{origin_style}]", + "✓", + ) + + console.print(table) + + # Let user toggle + console.print( + "\n[dim]Enter numbers to toggle off/on (e.g. '3 5 7'), or press Enter to accept all:[/dim]" + ) + toggle_input = Prompt.ask("Toggle", default="").strip() + + if toggle_input: + for num_str in toggle_input.split(): + try: + idx = int(num_str) - 1 + if 0 <= idx < len(enabled): + enabled[idx] = not enabled[idx] + except ValueError: + pass + + selected = [src for src, on in zip(all_sources, enabled) if on] + console.print(f"\n[green]✓ {len(selected)} sources selected[/green]") + return selected + + +def build_config( + ai_config: AIConfig, + selected_sources: List[Dict], +) -> Config: + """Step 6: Assemble the final Config object. + + Args: + ai_config: AI configuration. + selected_sources: List of selected source dicts. + + Returns: + Complete Config object. + """ + github_sources = [] + rss_sources = [] + reddit_subreddits = [] + reddit_users = [] + telegram_channels = [] + hn_enabled = False + + for src in selected_sources: + src_type = src.get("type", "") + cfg = src.get("config", {}) + + if src_type == "github_user": + github_sources.append(GitHubSourceConfig( + type="user_events", + username=cfg.get("username", ""), + enabled=True, + )) + elif src_type == "github_repo": + github_sources.append(GitHubSourceConfig( + type="repo_releases", + owner=cfg.get("owner", ""), + repo=cfg.get("repo", ""), + enabled=True, + )) + elif src_type == "rss": + rss_sources.append(RSSSourceConfig( + name=cfg.get("name", ""), + url=cfg.get("url", ""), + enabled=True, + category=cfg.get("category", ""), + )) + elif src_type == "reddit_subreddit": + reddit_subreddits.append(RedditSubredditConfig( + subreddit=cfg.get("subreddit", ""), + sort=cfg.get("sort", "hot"), + fetch_limit=cfg.get("fetch_limit", 15), + min_score=cfg.get("min_score", 50), + )) + elif src_type == "reddit_user": + reddit_users.append(RedditUserConfig( + username=cfg.get("username", ""), + )) + elif src_type == "telegram": + telegram_channels.append(TelegramChannelConfig( + channel=cfg.get("channel", ""), + fetch_limit=cfg.get("fetch_limit", 20), + )) + elif src_type == "hackernews": + hn_enabled = True + + # Always include HackerNews as a universal source + hn_config = HackerNewsConfig( + enabled=True, + fetch_top_stories=30, + min_score=100, + ) + + reddit_config = RedditConfig( + enabled=bool(reddit_subreddits or reddit_users), + subreddits=reddit_subreddits, + users=reddit_users, + fetch_comments=10, + ) + + telegram_config = TelegramConfig( + enabled=bool(telegram_channels), + channels=telegram_channels, + ) + + sources = SourcesConfig( + github=github_sources, + hackernews=hn_config, + rss=rss_sources, + reddit=reddit_config, + telegram=telegram_config, + ) + + filtering = FilteringConfig( + ai_score_threshold=7.0, + time_window_hours=24, + ) + + return Config( + version="1.0", + ai=ai_config, + sources=sources, + filtering=filtering, + ) + + +def merge_configs(new_config: Config, existing_config: Config) -> Config: + """Merge new config into existing config, deduplicating sources. + + Rules: + - ai / filtering: use new values (full replacement) + - sources: deduplicate by unique key, append new ones + - existing enabled=false sources are preserved + + Args: + new_config: Newly generated config. + existing_config: Existing config to merge into. + + Returns: + Merged Config object. + """ + merged = new_config.model_copy(deep=True) + + # Merge GitHub sources by unique key + existing_gh = {_gh_key(s): s for s in existing_config.sources.github} + for src in merged.sources.github: + key = _gh_key(src) + if key in existing_gh: + # Keep existing enabled state + src.enabled = existing_gh[key].enabled + del existing_gh[key] + # Append remaining existing sources + merged.sources.github.extend(existing_gh.values()) + + # Merge RSS sources by URL + existing_rss = {s.url: s for s in existing_config.sources.rss} + for src in merged.sources.rss: + if src.url in existing_rss: + src.enabled = existing_rss[src.url].enabled + del existing_rss[src.url] + merged.sources.rss.extend(existing_rss.values()) + + # Merge Reddit subreddits + existing_subs = { + s.subreddit: s + for s in (existing_config.sources.reddit.subreddits or []) + } + new_subs = [] + for sub in (merged.sources.reddit.subreddits or []): + name = sub.subreddit + if name in existing_subs: + del existing_subs[name] + new_subs.append(sub) + new_subs.extend(existing_subs.values()) + merged.sources.reddit.subreddits = new_subs + + return merged + + +def _gh_key(src: GitHubSourceConfig) -> str: + """Generate unique key for a GitHub source.""" + if src.type == "user_events": + return f"user:{src.username}" + return f"repo:{src.owner}/{src.repo}" + + +def main(): + """Main entry point for the setup wizard.""" + print_banner() + + storage = StorageManager(data_dir="data") + + # Step 1: AI configuration + ai_config = configure_ai() + if ai_config is None: + console.print("[red]Setup cancelled.[/red]") + sys.exit(0) + + # Step 2: Interest description + interests = get_interests() + + # Step 3: Preset library matching + console.print("\n[dim]Fetching preset source library...[/dim]") + try: + presets = load_presets(prefer_api=True) + offline = os.environ.get("HORIZON_OFFLINE", "").lower() in ("1", "true", "yes") + if offline: + console.print("[dim]Using local presets (offline mode)[/dim]") + else: + console.print("[dim]Loaded preset sources from API[/dim]") + except FileNotFoundError: + console.print("[yellow]Could not fetch presets (offline and no local file).[/yellow]") + console.print("[yellow]Skipping preset matching.[/yellow]") + presets = {"domains": []} + + matched_sources = match_sources(interests, presets) + preset_sources = [src for src, _ in matched_sources] + + if matched_sources: + console.print(f"[green]Found {len(preset_sources)} matching sources[/green]") + else: + console.print("[yellow]No preset sources matched — AI will try to recommend.[/yellow]") + + # Step 4: AI recommendations (optional) + ai_sources = [] + ai_available = _ai_recommendations_available(ai_config) + + if ai_available: + if Confirm.ask("\nAsk AI for additional source recommendations?", default=True): + console.print("[dim]Asking AI for recommendations...[/dim]") + from .ai_recommend import get_ai_recommendations_sync + + ai_sources = get_ai_recommendations_sync(ai_config, interests, preset_sources) + if ai_sources: + console.print(f"[green]AI recommended {len(ai_sources)} additional sources[/green]") + else: + console.print("[yellow]AI returned no additional recommendations.[/yellow]") + else: + console.print( + f"\n[dim]Skipping AI recommendations ({ai_config.api_key_env} not set)[/dim]" + ) + + # Step 5: Interactive source selection + selected = select_sources(preset_sources, ai_sources) + + if not selected: + console.print("[yellow]No sources selected. Adding HackerNews as default.[/yellow]") + + # Step 6: Build config + config = build_config(ai_config, selected) + + # Merge with existing config if present + try: + existing = storage.load_config() + if Confirm.ask("\nExisting config.json found. Merge new sources into it?", default=True): + config = merge_configs(config, existing) + except FileNotFoundError: + pass + + # Save + path = storage.save_config(config, backup=True) + + # Summary + console.print(Panel( + f"[green]✓ Configuration saved to {path}[/green]\n\n" + f" AI: {ai_config.provider.value} / {ai_config.model}\n" + f" Sources: {_count_sources(config)} total\n" + f" Threshold: {config.filtering.ai_score_threshold}\n\n" + f"Run [bold cyan]horizon[/bold cyan] to start aggregating!", + title="Setup Complete", + border_style="green", + )) + + +def _count_sources(config: Config) -> int: + """Count total number of enabled sources.""" + count = 0 + count += len([s for s in config.sources.github if s.enabled]) + if config.sources.hackernews.enabled: + count += 1 + count += len([s for s in config.sources.rss if s.enabled]) + if config.sources.reddit.enabled: + count += len(config.sources.reddit.subreddits or []) + count += len(config.sources.reddit.users or []) + if config.sources.telegram.enabled: + count += len(config.sources.telegram.channels or []) + return count diff --git a/src/storage/__init__.py b/src/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/storage/manager.py b/src/storage/manager.py new file mode 100644 index 0000000..676982b --- /dev/null +++ b/src/storage/manager.py @@ -0,0 +1,150 @@ +"""Storage manager for configuration and state persistence.""" + +import json +import os +import re +import shutil +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from ..models import Config + + +# Matches ${VAR_NAME} in string config values. Names follow env-var rules +# (ASCII letters, digits, underscore; must not start with a digit). +_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +def _expand_env_vars(value: Any) -> Any: + """Recursively expand ``${VAR}`` references inside any string leaves. + + Containers (dicts, lists, tuples) are walked; non-string leaves are + returned unchanged. Strings with no ``${...}`` tokens are returned + unchanged. References to unset variables are **left as-is**, so + ``${MISSING}`` round-trips to ``${MISSING}`` and surfaces as a clear + downstream error rather than a silent empty string. + + This is intentionally identical to the behaviour ``RSSScraper`` uses + for RSS feed URLs, so a single ``${VAR}`` convention works everywhere + in the config (AI ``base_url``, feed URLs, webhook URLs, ...). + """ + if isinstance(value, str): + return _ENV_VAR_PATTERN.sub( + lambda m: os.environ.get(m.group(1), m.group(0)), + value, + ) + if isinstance(value, dict): + return {k: _expand_env_vars(v) for k, v in value.items()} + if isinstance(value, list): + return [_expand_env_vars(v) for v in value] + if isinstance(value, tuple): + return tuple(_expand_env_vars(v) for v in value) + return value + + +class ConfigError(ValueError): + """Raised when configuration is missing or invalid.""" + + pass + + +class StorageManager: + """Manages file-based storage for configuration and state.""" + + def __init__(self, data_dir: str = "data"): + self.data_dir = Path(data_dir) + self.config_path = self.data_dir / "config.json" + self.summaries_dir = self.data_dir / "summaries" + + self.data_dir.mkdir(parents=True, exist_ok=True) + self.summaries_dir.mkdir(parents=True, exist_ok=True) + + def load_config(self) -> Config: + if not self.config_path.exists(): + raise FileNotFoundError( + f"Configuration file not found: {self.config_path}\n" + f"Please create it based on the template in README.md" + ) + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + raise ConfigError( + f"Invalid JSON in configuration file: {self.config_path}\n" f"Error: {e}" + ) from e + + # Expand ${VAR} references in every string value before pydantic + # validation. Keeps credentials / private endpoints / tenant IDs + # out of the JSON file so it is safe to commit to a public repo. + data = _expand_env_vars(data) + + try: + return Config.model_validate(data) + except ValidationError as e: + raise ConfigError( + f"Configuration validation failed for {self.config_path}\n" + f"Details: {e}" + ) from e + + def save_config(self, config: Config, backup: bool = True) -> Path: + """Save configuration to config.json, optionally backing up the existing file. + + Args: + config: The Config object to save. + backup: If True and config.json exists, copy it to config.json.bak first. + + Returns: + Path to the saved config file. + """ + if backup and self.config_path.exists(): + shutil.copy2(self.config_path, self.config_path.with_suffix(".json.bak")) + + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(config.model_dump(mode="json"), f, indent=2, ensure_ascii=False) + f.write("\n") + + return self.config_path + + def save_daily_summary(self, date: str, markdown: str, language: str = "en") -> Path: + filename = f"horizon-{date}-{language}.md" + filepath = self.summaries_dir / filename + + with open(filepath, "w", encoding="utf-8") as f: + f.write(markdown) + + return filepath + + def load_subscribers(self) -> list: + """Loads the list of email subscribers.""" + subscribers_path = self.data_dir / "subscribers.json" + if not subscribers_path.exists(): + return [] + + try: + with open(subscribers_path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + return [] + + def add_subscriber(self, email_addr: str): + """Adds a new subscriber email.""" + subscribers = self.load_subscribers() + if email_addr not in subscribers: + subscribers.append(email_addr) + self._save_subscribers(subscribers) + + def remove_subscriber(self, email_addr: str): + """Removes a subscriber email.""" + subscribers = self.load_subscribers() + if email_addr in subscribers: + subscribers.remove(email_addr) + self._save_subscribers(subscribers) + + def _save_subscribers(self, subscribers: list): + """Helper to save subscribers list.""" + subscribers_path = self.data_dir / "subscribers.json" + with open(subscribers_path, "w", encoding="utf-8") as f: + json.dump(subscribers, f, indent=2) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..41da167 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py new file mode 100644 index 0000000..a80e0b4 --- /dev/null +++ b/tests/test_analyzer.py @@ -0,0 +1,96 @@ +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace + +import src.ai.analyzer as analyzer_module +from src.ai.analyzer import ContentAnalyzer +from src.models import ContentItem, SourceType + + +def _make_item(item_id: str) -> ContentItem: + return ContentItem( + id=item_id, + source_type=SourceType.RSS, + title=f"Item {item_id}", + url="https://example.com/item", + published_at=datetime(2026, 4, 26, tzinfo=timezone.utc), + ) + + +def test_analyze_batch_does_not_sleep_by_default(monkeypatch): + analyzer = ContentAnalyzer(SimpleNamespace()) + items = [_make_item("rss:test:1"), _make_item("rss:test:2")] + sleep_calls = [] + + async def fake_analyze_item(item): + item.ai_score = 8.0 + + async def fake_sleep(seconds): + sleep_calls.append(seconds) + + monkeypatch.setattr(analyzer, "_analyze_item", fake_analyze_item) + monkeypatch.setattr(analyzer_module.asyncio, "sleep", fake_sleep) + + result = asyncio.run(analyzer.analyze_batch(items)) + + assert len(result) == 2 + assert sleep_calls == [] + + +def test_analyze_batch_sleeps_between_items_when_throttle_configured(monkeypatch): + client = SimpleNamespace(config=SimpleNamespace(throttle_sec=1.5)) + analyzer = ContentAnalyzer(client) + items = [_make_item("rss:test:1"), _make_item("rss:test:2"), _make_item("rss:test:3")] + sleep_calls = [] + + async def fake_analyze_item(item): + item.ai_score = 8.0 + + async def fake_sleep(seconds): + sleep_calls.append(seconds) + + monkeypatch.setattr(analyzer, "_analyze_item", fake_analyze_item) + monkeypatch.setattr(analyzer_module.asyncio, "sleep", fake_sleep) + + asyncio.run(analyzer.analyze_batch(items)) + + assert sleep_calls == [1.5, 1.5] + + +def test_analyze_batch_concurrent_processing(monkeypatch): + """Verify that higher concurrency allows overlapping item processing.""" + client = SimpleNamespace(config=SimpleNamespace(analysis_concurrency=3)) + analyzer = ContentAnalyzer(client) + items = [_make_item(f"rss:test:{i}") for i in range(5)] + active_count = 0 + max_active = 0 + + async def fake_analyze_item(item): + nonlocal active_count, max_active + active_count += 1 + max_active = max(max_active, active_count) + await asyncio.sleep(0.05) # Small delay to allow overlap + active_count -= 1 + + monkeypatch.setattr(analyzer, "_analyze_item", fake_analyze_item) + + asyncio.run(analyzer.analyze_batch(items)) + + assert max_active == 3 + assert all(item.ai_score is None for item in items) # None because fake_analyze_item doesn't set it + + +def test_analyze_batch_concurrent_preserves_order(monkeypatch): + """Verify that analyze_batch preserves input order in results.""" + client = SimpleNamespace(config=SimpleNamespace(analysis_concurrency=3)) + analyzer = ContentAnalyzer(client) + items = [_make_item(f"rss:test:{i}") for i in range(5)] + + async def fake_analyze_item(item): + item.ai_score = float(item.id.split(":")[-1]) * 10 + + monkeypatch.setattr(analyzer, "_analyze_item", fake_analyze_item) + + result = asyncio.run(analyzer.analyze_batch(items)) + + assert [item.id for item in result] == [item.id for item in items] diff --git a/tests/test_azure_client.py b/tests/test_azure_client.py new file mode 100644 index 0000000..f0d5742 --- /dev/null +++ b/tests/test_azure_client.py @@ -0,0 +1,108 @@ +"""Tests for Azure OpenAI client.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from src.ai.client import AzureOpenAIClient, create_ai_client +from src.models import AIConfig, AIProvider + + +def _make_config(**overrides) -> AIConfig: + defaults = { + "provider": AIProvider.AZURE, + "model": "gpt-4o-deployment", + "api_key_env": "AZURE_OPENAI_API_KEY", + "azure_endpoint_env": "AZURE_OPENAI_ENDPOINT", + "api_version": "2024-10-21", + "temperature": 0.3, + "max_tokens": 4096, + } + defaults.update(overrides) + return AIConfig(**defaults) + + +def _mock_response(content: str = '{"ok": true}'): + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=content))], + usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5), + ) + + +class TestAzureOpenAIClientInit: + def test_creates_instance_with_valid_config(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com") + + client = AzureOpenAIClient(_make_config()) + + assert client.model == "gpt-4o-deployment" + assert client.max_tokens == 4096 + + def test_raises_when_api_key_missing(self, monkeypatch): + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com") + + with pytest.raises(ValueError, match="Missing API key"): + AzureOpenAIClient(_make_config()) + + def test_raises_when_endpoint_missing(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-key") + monkeypatch.delenv("AZURE_OPENAI_ENDPOINT", raising=False) + + with pytest.raises(ValueError, match="Missing Azure endpoint"): + AzureOpenAIClient(_make_config()) + + +class TestAzureOpenAIClientComplete: + def test_uses_max_completion_tokens_for_gpt5_prefix(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com") + client = AzureOpenAIClient(_make_config(model="gpt-5-mini")) + + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = _mock_response() + asyncio.run(client.complete(system="test", user="hello")) + + call_kwargs = mock_create.call_args[1] + assert call_kwargs["max_completion_tokens"] == 4096 + assert "max_tokens" not in call_kwargs + + def test_retries_with_max_completion_tokens_for_custom_deployment(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com") + client = AzureOpenAIClient(_make_config(model="prod-gpt5-nano")) + + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [ + RuntimeError("Unsupported parameter: 'max_tokens'. Use 'max_completion_tokens' instead."), + _mock_response(), + ] + result = asyncio.run(client.complete(system="test", user="hello")) + + assert result == '{"ok": true}' + first_call = mock_create.call_args_list[0].kwargs + second_call = mock_create.call_args_list[1].kwargs + assert first_call["max_tokens"] == 4096 + assert "max_completion_tokens" not in first_call + assert second_call["max_completion_tokens"] == 4096 + assert "max_tokens" not in second_call + assert client._use_max_completion_tokens is True + + +class TestFactoryFunction: + def test_creates_azure_client(self, monkeypatch): + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-key") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com") + + client = create_ai_client(_make_config()) + + assert isinstance(client, AzureOpenAIClient) diff --git a/tests/test_balanced_digest.py b/tests/test_balanced_digest.py new file mode 100644 index 0000000..6a51458 --- /dev/null +++ b/tests/test_balanced_digest.py @@ -0,0 +1,190 @@ +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError +from rich.console import Console + +from src.models import ( + AIConfig, + CategoryGroupConfig, + Config, + ContentItem, + FilteringConfig, + SourceType, + SourcesConfig, +) +from src.orchestrator import HorizonOrchestrator + + +def make_item(item_id: str, score: float, category: str | None) -> ContentItem: + metadata = {"category": category} if category is not None else {} + return ContentItem( + id=item_id, + source_type=SourceType.RSS, + title=item_id, + url=f"https://example.com/{item_id}", + published_at=datetime.now(timezone.utc), + ai_score=score, + metadata=metadata, + ) + + +def make_orchestrator(filtering: FilteringConfig) -> HorizonOrchestrator: + orchestrator = HorizonOrchestrator.__new__(HorizonOrchestrator) + orchestrator.config = SimpleNamespace(filtering=filtering) + orchestrator.console = Console(record=True) + return orchestrator + + +def test_unconfigured_balanced_digest_preserves_old_behavior() -> None: + items = [make_item("lower", 7.0, "ai"), make_item("higher", 9.0, "finance")] + result = make_orchestrator(FilteringConfig()).apply_balanced_digest(items) + + assert result.enabled is False + assert result.items is items + + +def test_category_groups_apply_limits_and_default_group_limit() -> None: + filtering = FilteringConfig( + category_groups={ + "ai": CategoryGroupConfig(limit=2, categories=["ai", "ml"]), + "finance": CategoryGroupConfig(limit=1, categories=["finance"]), + }, + default_group_limit=1, + ) + items = [ + make_item("ai-low", 7.0, "ai"), + make_item("finance-low", 6.0, "finance"), + make_item("other-high", 9.5, "world"), + make_item("ai-high", 9.0, "ml"), + make_item("finance-high", 8.5, "finance"), + make_item("ai-mid", 8.0, "ai"), + make_item("other-low", 5.0, None), + ] + + result = make_orchestrator(filtering).apply_balanced_digest(items) + + assert [item.id for item in result.items] == [ + "other-high", + "ai-high", + "finance-high", + "ai-mid", + ] + assert result.group_counts == {"other": 1, "ai": 2, "finance": 1} + + +def test_max_items_applies_after_group_limits() -> None: + filtering = FilteringConfig( + max_items=2, + category_groups={ + "ai": CategoryGroupConfig(limit=2, categories=["ai"]), + "finance": CategoryGroupConfig(limit=2, categories=["finance"]), + }, + ) + items = [ + make_item("finance", 8.0, "finance"), + make_item("ai-top", 10.0, "ai"), + make_item("ai-second", 9.0, "ai"), + ] + + result = make_orchestrator(filtering).apply_balanced_digest(items) + + assert [item.id for item in result.items] == ["ai-top", "ai-second"] + assert result.group_counts == {"ai": 2} + + +def test_max_items_works_without_category_groups() -> None: + filtering = FilteringConfig(max_items=1) + items = [make_item("lower", 7.0, None), make_item("higher", 9.0, None)] + + result = make_orchestrator(filtering).apply_balanced_digest(items) + + assert [item.id for item in result.items] == ["higher"] + + +def test_duplicate_category_warns_and_first_group_wins() -> None: + filtering = FilteringConfig( + category_groups={ + "first": CategoryGroupConfig(limit=1, categories=["shared"]), + "second": CategoryGroupConfig(limit=2, categories=["shared"]), + } + ) + orchestrator = make_orchestrator(filtering) + + result = orchestrator.apply_balanced_digest( + [make_item("top", 9.0, "shared"), make_item("second", 8.0, "shared")] + ) + + assert [item.id for item in result.items] == ["top"] + assert result.duplicate_categories == ["shared"] + assert "using 'first'" in orchestrator.console.export_text() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"max_items": 0}, + {"default_group_limit": 0}, + {"category_groups": {"ai": {"limit": 0, "categories": ["ai"]}}}, + {"category_groups": {"ai": {"limit": 1, "categories": []}}}, + ], +) +def test_balanced_digest_config_rejects_non_positive_or_empty_limits(kwargs) -> None: + with pytest.raises(ValidationError): + FilteringConfig(**kwargs) + + +def test_run_applies_balanced_digest_before_enrichment(tmp_path, monkeypatch) -> None: + config = Config( + ai=AIConfig( + provider="openai", + model="test", + api_key_env="TEST_API_KEY", + languages=[], + ), + sources=SourcesConfig(), + filtering=FilteringConfig( + ai_score_threshold=7.0, + max_items=1, + category_groups={ + "ai": CategoryGroupConfig(limit=1, categories=["ai"]), + "finance": CategoryGroupConfig(limit=1, categories=["finance"]), + }, + ), + ) + storage = SimpleNamespace() + orchestrator = HorizonOrchestrator(config, storage) + items = [ + make_item("ai", 9.0, "ai"), + make_item("finance", 8.0, "finance"), + make_item("below-threshold", 6.0, "ai"), + ] + enriched_ids: list[str] = [] + + async def fetch_all_sources(since): # type: ignore[no-untyped-def] + return items + + async def analyze_content(input_items): # type: ignore[no-untyped-def] + return input_items + + async def merge_topic_duplicates(input_items): # type: ignore[no-untyped-def] + return input_items + + async def expand_twitter_discussion(input_items): # type: ignore[no-untyped-def] + return None + + async def enrich_important_items(input_items): # type: ignore[no-untyped-def] + enriched_ids.extend(item.id for item in input_items) + + monkeypatch.setattr(orchestrator, "fetch_all_sources", fetch_all_sources) + monkeypatch.setattr(orchestrator, "_analyze_content", analyze_content) + monkeypatch.setattr(orchestrator, "merge_topic_duplicates", merge_topic_duplicates) + monkeypatch.setattr(orchestrator, "_expand_twitter_discussion", expand_twitter_discussion) + monkeypatch.setattr(orchestrator, "_enrich_important_items", enrich_important_items) + monkeypatch.chdir(tmp_path) + + asyncio.run(orchestrator.run()) + + assert enriched_ids == ["ai"] diff --git a/tests/test_category_wiring.py b/tests/test_category_wiring.py new file mode 100644 index 0000000..c25a9ef --- /dev/null +++ b/tests/test_category_wiring.py @@ -0,0 +1,335 @@ +"""Tests that category from source config flows into item metadata for all scrapers.""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest +from bs4 import BeautifulSoup + +from src.models import ( + GitHubSourceConfig, + HackerNewsConfig, + OSSInsightConfig, + RedditSubredditConfig, + RedditUserConfig, + TelegramChannelConfig, + TelegramConfig, + TwitterConfig, +) +from src.scrapers.github import GitHubScraper +from src.scrapers.hackernews import HackerNewsScraper +from src.scrapers.ossinsight import OSSInsightScraper +from src.scrapers.reddit import RedditScraper +from src.scrapers.telegram import TelegramScraper +from src.scrapers.twitter import TwitterScraper + +_SINCE = datetime(2020, 1, 1, tzinfo=timezone.utc) +_NOW = datetime(2025, 1, 1, 12, 0, tzinfo=timezone.utc) + + +# --------------------------------------------------------------------------- +# HackerNews +# --------------------------------------------------------------------------- + + +def test_hackernews_category_in_metadata(): + cfg = HackerNewsConfig(category="tech") + scraper = HackerNewsScraper(cfg, AsyncMock()) + story = { + "id": 1, + "title": "A story", + "url": "https://example.com", + "by": "user", + "time": int(_NOW.timestamp()), + "score": 200, + "type": "story", + "descendants": 10, + } + item = scraper._parse_story(story, []) + assert item.metadata["category"] == "tech" + + +def test_hackernews_category_none_when_unset(): + cfg = HackerNewsConfig() + scraper = HackerNewsScraper(cfg, AsyncMock()) + story = { + "id": 2, + "title": "Another story", + "url": "https://example.com/2", + "by": "user", + "time": int(_NOW.timestamp()), + "score": 150, + "type": "story", + "descendants": 0, + } + item = scraper._parse_story(story, []) + assert item.metadata["category"] is None + + +# --------------------------------------------------------------------------- +# GitHub +# --------------------------------------------------------------------------- + + +def _github_event(event_type: str = "PushEvent") -> dict: + return { + "id": "42", + "type": event_type, + "created_at": _NOW.isoformat().replace("+00:00", "Z"), + "actor": {"login": "alice"}, + "repo": {"name": "alice/repo"}, + "payload": {"commits": [{"message": "init"}]}, + } + + +def test_github_parse_event_category_in_metadata(): + source = GitHubSourceConfig(type="user_events", username="alice", category="oss") + scraper = GitHubScraper([source], AsyncMock()) + item = scraper._parse_event(_github_event(), source) + assert item is not None + assert item.metadata["category"] == "oss" + + +def test_github_parse_event_category_none_when_unset(): + source = GitHubSourceConfig(type="user_events", username="alice") + scraper = GitHubScraper([source], AsyncMock()) + item = scraper._parse_event(_github_event(), source) + assert item is not None + assert item.metadata["category"] is None + + +# --------------------------------------------------------------------------- +# Reddit +# --------------------------------------------------------------------------- + + +def _reddit_post(subreddit: str = "LocalLLaMA") -> dict: + return { + "id": "xyz", + "title": "Post title", + "is_self": True, + "subreddit": subreddit, + "permalink": f"/r/{subreddit}/comments/xyz/", + "author": "tester", + "created_utc": _NOW.timestamp(), + "score": 100, + "upvote_ratio": 0.95, + "num_comments": 3, + "selftext": "", + } + + +def _reddit_config(category: str | None = None): + from src.models import RedditConfig + + return RedditConfig( + enabled=True, + subreddits=[ + RedditSubredditConfig( + subreddit="LocalLLaMA", + enabled=True, + sort="hot", + fetch_limit=5, + min_score=1, + category=category, + ) + ], + fetch_comments=0, + ) + + +def test_reddit_parse_post_category_in_metadata(): + scraper = RedditScraper(_reddit_config("ai"), AsyncMock()) + item = scraper._parse_post(_reddit_post(), [], "subreddit", category="ai") + assert item is not None + assert item.metadata["category"] == "ai" + + +def test_reddit_parse_post_category_none_when_unset(): + scraper = RedditScraper(_reddit_config(), AsyncMock()) + item = scraper._parse_post(_reddit_post(), [], "subreddit") + assert item is not None + assert item.metadata["category"] is None + + +def test_reddit_rss_fallback_category_in_metadata(): + from src.models import RedditConfig + + cfg = RedditSubredditConfig( + subreddit="LocalLLaMA", sort="hot", fetch_limit=5, min_score=1, category="forum" + ) + config = RedditConfig(enabled=True, subreddits=[cfg], fetch_comments=0) + scraper = RedditScraper(config, AsyncMock()) + + rss_xml = f""" + + + https://www.reddit.com/r/LocalLLaMA/comments/abc/test/ + RSS post + + {_NOW.strftime('%Y-%m-%dT%H:%M:%S+00:00')} + rssuser + body text + + """ + + import feedparser + + feed = feedparser.parse(rss_xml) + # Simulate what _fetch_subreddit_rss produces from the parsed feed + from src.models import SourceType + from src.scrapers.reddit import RedditScraper as RS + + items = [] + for entry in feed.entries[: cfg.fetch_limit]: + published_at = scraper._parse_rss_date(entry) + if not published_at or published_at < _SINCE: + continue + entry_id = str(entry.get("id") or entry.get("link") or "") + link = str(entry.get("link") or "https://reddit.com/") + from typing import Any, cast + + items.append( + __import__("src.models", fromlist=["ContentItem"]).ContentItem( + id=scraper._generate_id("reddit", "subreddit-rss", entry_id), + source_type=SourceType.REDDIT, + title=str(entry.get("title") or "Untitled"), + url=cast(Any, link), + content="body", + author="rssuser", + published_at=published_at, + metadata={ + "score": None, + "upvote_ratio": None, + "num_comments": None, + "subreddit": cfg.subreddit, + "is_self": None, + "flair": None, + "discussion_url": link, + "fallback": "rss", + "category": cfg.category, + }, + ) + ) + assert len(items) == 1 + assert items[0].metadata["category"] == "forum" + + +def test_reddit_user_config_has_category_field(): + cfg = RedditUserConfig(username="alice", category="personal") + assert cfg.category == "personal" + + +# --------------------------------------------------------------------------- +# Telegram +# --------------------------------------------------------------------------- + +_TELEGRAM_MSG_HTML = """ +
    +
    Hello world
    + +
    +""" + + +def _tg_msg_el(): + return BeautifulSoup(_TELEGRAM_MSG_HTML, "html.parser").select_one( + "div.tgme_widget_message" + ) + + +def test_telegram_parse_message_category_in_metadata(): + cfg = TelegramChannelConfig(channel="channel", category="news") + scraper = TelegramScraper(TelegramConfig(), AsyncMock()) + item = scraper._parse_message(_tg_msg_el(), cfg, _SINCE) + assert item is not None + assert item.metadata["category"] == "news" + + +def test_telegram_parse_message_category_none_when_unset(): + cfg = TelegramChannelConfig(channel="channel") + scraper = TelegramScraper(TelegramConfig(), AsyncMock()) + item = scraper._parse_message(_tg_msg_el(), cfg, _SINCE) + assert item is not None + assert item.metadata["category"] is None + + +def test_telegram_parse_channel_html_passes_category(): + cfg = TelegramChannelConfig(channel="channel", fetch_limit=10, category="ai-news") + scraper = TelegramScraper(TelegramConfig(), AsyncMock()) + items = scraper._parse_channel_html(_TELEGRAM_MSG_HTML, cfg, _SINCE) + assert len(items) == 1 + assert items[0].metadata["category"] == "ai-news" + + +# --------------------------------------------------------------------------- +# OSSInsight +# --------------------------------------------------------------------------- + + +def test_ossinsight_row_to_item_category_in_metadata(): + cfg = OSSInsightConfig(enabled=True, category="oss-trending") + scraper = OSSInsightScraper(cfg, AsyncMock()) + row = { + "repo_name": "owner/repo", + "repo_id": 123, + "stars": 50, + "forks": 10, + "pushes": 5, + "pull_requests": 3, + } + item = scraper._row_to_item(row, "Python") + assert item is not None + assert item.metadata["category"] == "oss-trending" + + +def test_ossinsight_row_to_item_category_none_when_unset(): + cfg = OSSInsightConfig(enabled=True) + scraper = OSSInsightScraper(cfg, AsyncMock()) + row = { + "repo_name": "owner/repo", + "repo_id": 456, + "stars": 20, + "forks": 5, + "pushes": 2, + "pull_requests": 1, + } + item = scraper._row_to_item(row, "Go") + assert item is not None + assert item.metadata["category"] is None + + +# --------------------------------------------------------------------------- +# Twitter (Apify) +# --------------------------------------------------------------------------- + + +def _twitter_raw_item() -> dict: + return { + "id": "tweet-999", + "tweet_id": "999", + "full_text": "Hello twitter", + "user": {"screen_name": "alice", "name": "Alice"}, + "created_at": "Wed Jan 01 12:00:00 +0000 2025", + "favorite_count": 10, + "retweet_count": 2, + "reply_count": 1, + "is_reply": False, + "conversation_id": "999", + } + + +def test_twitter_parse_item_category_in_metadata(): + cfg = TwitterConfig(enabled=True, users=["alice"], category="social") + scraper = TwitterScraper(cfg, AsyncMock()) + item = scraper._parse_item(_twitter_raw_item(), _SINCE) + assert item is not None + assert item.metadata["category"] == "social" + + +def test_twitter_parse_item_category_none_when_unset(): + cfg = TwitterConfig(enabled=True, users=["alice"]) + scraper = TwitterScraper(cfg, AsyncMock()) + item = scraper._parse_item(_twitter_raw_item(), _SINCE) + assert item is not None + assert item.metadata["category"] is None diff --git a/tests/test_chained_client.py b/tests/test_chained_client.py new file mode 100644 index 0000000..15b5bd7 --- /dev/null +++ b/tests/test_chained_client.py @@ -0,0 +1,187 @@ +"""Tests for ChainedAIClient fallback logic.""" + +import asyncio +from datetime import datetime, timezone + +import pytest + +from src.ai.client import ChainedAIClient, _create_chained_client +from src.models import AIConfig, AIProvider, ContentItem, SourceType + + +class _DummyClient: + """Mock AI client for testing.""" + + def __init__(self, result=None, exc=None): + self.result = result + self.exc = exc + self.calls = [] + + async def complete(self, system, user, temperature=None, max_tokens=None): + self.calls.append((system, user, temperature, max_tokens)) + if self.exc: + raise self.exc + return self.result + + +class _MockFactory: + """Factory that returns pre-built dummy clients in order.""" + + def __init__(self, *clients): + self.clients = list(clients) + self.calls = [] + self.idx = 0 + + def __call__(self, cfg): + self.calls.append(cfg) + client = self.clients[self.idx] + self.idx += 1 + return client + + +def _make_config(provider: AIProvider, model: str = "m", api_key_env: str = "K") -> AIConfig: + return AIConfig( + provider=provider, + model=model, + api_key_env=api_key_env, + ) + + +def test_success_on_first_provider(): + """When first provider succeeds, no fallback occurs.""" + cfg1 = _make_config(AIProvider.OPENAI) + cfg2 = _make_config(AIProvider.OLLAMA) + client1 = _DummyClient(result="ok") + client2 = _DummyClient(result="also ok") + + chained = ChainedAIClient([cfg1, cfg2], clients=[client1, client2]) + result = asyncio.run(chained.complete("sys", "usr")) + + assert result == "ok" + assert len(client1.calls) == 1 + assert len(client2.calls) == 0 + + +def test_fallback_on_empty_response(): + """When first provider returns empty/whitespace, fallback to second.""" + cfg1 = _make_config(AIProvider.OPENAI) + cfg2 = _make_config(AIProvider.OLLAMA) + client1 = _DummyClient(result=" ") + client2 = _DummyClient(result="fallback ok") + + chained = ChainedAIClient([cfg1, cfg2], clients=[client1, client2]) + result = asyncio.run(chained.complete("sys", "usr")) + + assert result == "fallback ok" + assert len(client1.calls) == 1 + assert len(client2.calls) == 1 + + +def test_fallback_on_rate_limit(): + """When first provider hits 429, fallback to second.""" + cfg1 = _make_config(AIProvider.OPENAI) + cfg2 = _make_config(AIProvider.OLLAMA) + client1 = _DummyClient(exc=Exception("429 rate limit exceeded")) + client2 = _DummyClient(result="fallback ok") + + chained = ChainedAIClient([cfg1, cfg2], clients=[client1, client2]) + result = asyncio.run(chained.complete("sys", "usr")) + + assert result == "fallback ok" + assert len(client1.calls) == 1 + assert len(client2.calls) == 1 + + +def test_fallback_on_quota_exceeded(): + """When first provider quota exhausted, fallback to second.""" + cfg1 = _make_config(AIProvider.OPENAI) + cfg2 = _make_config(AIProvider.GEMINI) + client1 = _DummyClient(exc=Exception("403 quota exceeded")) + client2 = _DummyClient(result="fallback ok") + + chained = ChainedAIClient([cfg1, cfg2], clients=[client1, client2]) + result = asyncio.run(chained.complete("sys", "usr")) + + assert result == "fallback ok" + + +def test_all_providers_fail(): + """When all providers fail, raise RuntimeError.""" + cfg1 = _make_config(AIProvider.OPENAI) + cfg2 = _make_config(AIProvider.OLLAMA) + client1 = _DummyClient(exc=Exception("429 rate limit")) + client2 = _DummyClient(exc=Exception("503 service unavailable")) + + chained = ChainedAIClient([cfg1, cfg2], clients=[client1, client2]) + with pytest.raises(RuntimeError, match="All providers failed"): + asyncio.run(chained.complete("sys", "usr")) + + +def test_no_fallback_on_unexpected_error(): + """Non-retryable errors should not trigger fallback.""" + cfg1 = _make_config(AIProvider.OPENAI) + cfg2 = _make_config(AIProvider.OLLAMA) + client1 = _DummyClient(exc=ValueError("Invalid JSON schema")) + client2 = _DummyClient(result="fallback ok") + + chained = ChainedAIClient([cfg1, cfg2], clients=[client1, client2]) + with pytest.raises(ValueError, match="Invalid JSON schema"): + asyncio.run(chained.complete("sys", "usr")) + + assert len(client2.calls) == 0 + + +def test_should_fallback_detects_retryable_errors(): + """_should_fallback correctly identifies retryable errors.""" + assert ChainedAIClient._should_fallback(Exception("429 rate limit")) is True + assert ChainedAIClient._should_fallback(Exception("401 unauthorized")) is True + assert ChainedAIClient._should_fallback(Exception("403 forbidden")) is True + assert ChainedAIClient._should_fallback(Exception("quota exceeded")) is True + assert ChainedAIClient._should_fallback(Exception("502 bad gateway")) is True + assert ChainedAIClient._should_fallback(Exception("503 service unavailable")) is True + assert ChainedAIClient._should_fallback(Exception("Empty response from provider")) is True + assert ChainedAIClient._should_fallback(Exception("some random error")) is False + + +def test_lazy_initialization(): + """Downstream clients are not instantiated when the first provider succeeds.""" + cfg1 = _make_config(AIProvider.OPENAI) + cfg2 = _make_config(AIProvider.OLLAMA) + client1 = _DummyClient(result="ok") + client2 = _DummyClient(result="also ok") + + factory = _MockFactory(client1, client2) + chained = ChainedAIClient([cfg1, cfg2], client_factory=factory) + result = asyncio.run(chained.complete("sys", "usr")) + + assert result == "ok" + assert len(factory.calls) == 1 + assert factory.calls[0].provider == AIProvider.OPENAI + + +def test_create_chained_client_parses_chain(): + """_create_chained_client correctly parses provider chain string.""" + config = AIConfig( + provider=AIProvider.OPENAI, + model="m1", + api_key_env="K1", + provider_chain="openai,ollama", + ) + chained = _create_chained_client(config) + assert len(chained.configs) == 2 + assert chained.configs[0].provider == AIProvider.OPENAI + assert chained.configs[1].provider == AIProvider.OLLAMA + assert chained.configs[1].model == "llama3.1" + assert chained.configs[1].api_key_env == "" + + +def test_create_chained_client_rejects_unknown_provider(): + """_create_chained_client rejects unknown providers in chain.""" + config = AIConfig( + provider=AIProvider.OPENAI, + model="m1", + api_key_env="K1", + provider_chain="openai,unknownprovider", + ) + with pytest.raises(ValueError, match="Unsupported AI provider in chain"): + _create_chained_client(config) diff --git a/tests/test_email.py b/tests/test_email.py new file mode 100644 index 0000000..a51e371 --- /dev/null +++ b/tests/test_email.py @@ -0,0 +1,176 @@ +from email.mime.multipart import MIMEMultipart + +from src.models import EmailConfig +from src.services.email import EmailManager + + +class FakeSMTP: + instances = [] + + def __init__(self, server, port): + self.server = server + self.port = port + self.login_calls = [] + self.messages = [] + FakeSMTP.instances.append(self) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def login(self, username, password): + self.login_calls.append((username, password)) + + def send_message(self, message): + self.messages.append(message) + + +class FakeIMAP: + instances = [] + + def __init__(self, server, port): + FakeIMAP.instances.append((server, port)) + + +def _email_config(**overrides): + data = { + "enabled": True, + "smtp_server": "smtp.example.com", + "smtp_port": 465, + "imap_server": "imap.example.com", + "imap_port": 993, + "email_address": "noreply@example.com", + "password_env": "EMAIL_PASSWORD", + } + data.update(overrides) + return EmailConfig(**data) + + +def test_send_daily_summary_uses_smtp_username_when_configured(monkeypatch): + monkeypatch.setenv("EMAIL_PASSWORD", "secret") + monkeypatch.setattr("src.services.email.smtplib.SMTP_SSL", FakeSMTP) + FakeSMTP.instances = [] + + config = _email_config(smtp_username="resend") + manager = EmailManager(config) + + manager.send_daily_summary("# Hello", "Daily", ["user@example.com"]) + + smtp = FakeSMTP.instances[0] + assert smtp.login_calls == [("resend", "secret")] + assert len(smtp.messages) == 1 + assert isinstance(smtp.messages[0], MIMEMultipart) + assert smtp.messages[0]["From"] == "Horizon Daily " + assert smtp.messages[0]["To"] == "user@example.com" + + +def test_send_daily_summary_falls_back_to_email_address_for_smtp_login(monkeypatch): + monkeypatch.setenv("EMAIL_PASSWORD", "secret") + monkeypatch.setattr("src.services.email.smtplib.SMTP_SSL", FakeSMTP) + FakeSMTP.instances = [] + + config = _email_config() + manager = EmailManager(config) + + manager.send_daily_summary("# Hello", "Daily", ["user@example.com"]) + + assert FakeSMTP.instances[0].login_calls == [("noreply@example.com", "secret")] + + +def test_send_daily_summary_escapes_raw_html(monkeypatch): + monkeypatch.setenv("EMAIL_PASSWORD", "secret") + monkeypatch.setattr("src.services.email.smtplib.SMTP_SSL", FakeSMTP) + FakeSMTP.instances = [] + + manager = EmailManager(_email_config()) + + manager.send_daily_summary( + "# Hello\n\n", "Daily", ["user@example.com"] + ) + + html_part = FakeSMTP.instances[0].messages[0].get_payload()[1] + html_body = html_part.get_payload(decode=True).decode() + assert "

    Hello

    " in html_body + assert " +## Item + +
    参考链接 + +
    +""" + + manager.send_daily_summary(summary, "Daily", ["user@example.com"]) + + message = FakeSMTP.instances[0].messages[0] + text_body = message.get_payload()[0].get_payload(decode=True).decode() + html_body = message.get_payload()[1].get_payload(decode=True).decode() + + assert '' not in text_body + assert "
    " not in text_body + assert "" not in text_body + assert "**参考链接**" in text_body + assert "- [Example A](https://example.com/a)" in text_body + + assert '<a id="item-1"></a>' not in html_body + assert "<details>" not in html_body + assert "<summary>" not in html_body + assert "参考链接" in html_body + assert 'Example A' in html_body + assert 'Example B' in html_body + + +def test_send_daily_summary_does_not_link_unsafe_details_href(monkeypatch): + monkeypatch.setenv("EMAIL_PASSWORD", "secret") + monkeypatch.setattr("src.services.email.smtplib.SMTP_SSL", FakeSMTP) + FakeSMTP.instances = [] + + manager = EmailManager(_email_config()) + summary = """# Daily + +
    References + +
    +""" + + manager.send_daily_summary(summary, "Daily", ["user@example.com"]) + + message = FakeSMTP.instances[0].messages[0] + text_body = message.get_payload()[0].get_payload(decode=True).decode() + html_body = message.get_payload()[1].get_payload(decode=True).decode() + + assert 'href="javascript:alert(1)"' not in html_body + assert "[click](javascript:alert(1))" not in text_body + assert "- click \\[me\\]\\(https://evil.example\\)" in text_body + assert "click [me](https://evil.example)" in html_body + + +def test_check_subscriptions_skips_imap_when_disabled(monkeypatch): + monkeypatch.setenv("EMAIL_PASSWORD", "secret") + monkeypatch.setattr("src.services.email.imaplib.IMAP4_SSL", FakeIMAP) + FakeIMAP.instances = [] + + config = _email_config(imap_enabled=False) + manager = EmailManager(config) + + manager.check_subscriptions(storage_manager=object()) + + assert FakeIMAP.instances == [] diff --git a/tests/test_extractors_registry.py b/tests/test_extractors_registry.py new file mode 100644 index 0000000..f425ca6 --- /dev/null +++ b/tests/test_extractors_registry.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from src.extractors import ExtractorRegistry, TrafilaturaExtractor +from src.models import TrafilaturaExtractorConfig + + +def test_default_trafilatura_registered(): + registry = ExtractorRegistry({}) + assert isinstance(registry.get("trafilatura"), TrafilaturaExtractor) + + +def test_default_trafilatura_uses_default_config(): + registry = ExtractorRegistry({}) + extractor = registry.get("trafilatura") + assert extractor._config == TrafilaturaExtractorConfig() + + +def test_unknown_name_returns_none(): + registry = ExtractorRegistry({}) + assert registry.get("nonexistent") is None + + +def test_user_config_overrides_default(): + registry = ExtractorRegistry({"trafilatura": TrafilaturaExtractorConfig(favor_precision=True)}) + extractor = registry.get("trafilatura") + assert isinstance(extractor, TrafilaturaExtractor) + assert extractor._config.favor_precision is True + + +def test_named_entry_added_alongside_defaults(): + registry = ExtractorRegistry({"my-ext": TrafilaturaExtractorConfig(favor_recall=True)}) + named = registry.get("my-ext") + assert isinstance(named, TrafilaturaExtractor) + assert named._config.favor_recall is True + assert registry.get("trafilatura") is not None diff --git a/tests/test_extractors_trafilatura.py b/tests/test_extractors_trafilatura.py new file mode 100644 index 0000000..ffda5fd --- /dev/null +++ b/tests/test_extractors_trafilatura.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import asyncio +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from src.extractors import TrafilaturaExtractor +from src.models import TrafilaturaExtractorConfig + +URL = "https://example.com/article" + + +def _extractor() -> TrafilaturaExtractor: + return TrafilaturaExtractor(TrafilaturaExtractorConfig()) + + +def _client(text: str = "

    Article text.

    ", status: int = 200) -> AsyncMock: + response = MagicMock() + response.text = text + response.raise_for_status.return_value = None + if status >= 400: + response.raise_for_status.side_effect = httpx.HTTPStatusError( + message=str(status), request=MagicMock(), response=MagicMock() + ) + client = AsyncMock() + client.get.return_value = response + return client + + +def _trafilatura_mock(extracted: str) -> MagicMock: + mod = MagicMock() + mod.extract.return_value = extracted + return mod + + +def test_returns_extracted_text(): + client = _client() + with patch.dict(sys.modules, {"trafilatura": _trafilatura_mock("Extracted article text.")}): + result = asyncio.run(_extractor().extract(URL, client)) + assert result == "Extracted article text." + client.get.assert_awaited_once_with(URL, follow_redirects=True) + + +def test_returns_none_when_trafilatura_returns_empty(): + client = _client() + with patch.dict(sys.modules, {"trafilatura": _trafilatura_mock("")}): + result = asyncio.run(_extractor().extract(URL, client)) + assert result is None + + +def test_returns_none_on_http_error(): + client = _client(status=404) + with patch.dict(sys.modules, {"trafilatura": _trafilatura_mock("text")}): + result = asyncio.run(_extractor().extract(URL, client)) + assert result is None + + +def test_returns_none_when_trafilatura_raises(): + client = _client() + mock_traf = _trafilatura_mock("text") + mock_traf.extract.side_effect = RuntimeError("parse error") + with patch.dict(sys.modules, {"trafilatura": mock_traf}): + result = asyncio.run(_extractor().extract(URL, client)) + assert result is None + + +def test_returns_none_when_not_installed(): + client = _client() + with patch.dict(sys.modules, {"trafilatura": None}): + result = asyncio.run(_extractor().extract(URL, client)) + assert result is None diff --git a/tests/test_gdelt.py b/tests/test_gdelt.py new file mode 100644 index 0000000..e250dc5 --- /dev/null +++ b/tests/test_gdelt.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import httpx + +from src.models import GDELTConfig +from src.scrapers.gdelt import GDELTScraper + + +SINCE = datetime(2026, 6, 27, 8, 30, 0, tzinfo=timezone.utc) + + +def _mock_client(payload: dict | None = None) -> AsyncMock: + response = MagicMock() + response.json.return_value = payload if payload is not None else {} + response.raise_for_status.return_value = None + client = AsyncMock() + client.get.return_value = response + return client + + +def _articles_payload() -> dict: + return { + "articles": [ + { + "url": "https://example.com/ai-1", + "title": "AI breakthrough one", + "seendate": "20260628T120000Z", + "domain": "example.com", + "sourcecountry": "United States", + "language": "English", + "socialimage": "https://example.com/img1.jpg", + }, + { + "url": "https://news.test/ai-2", + "title": "AI breakthrough two", + "seendate": "20260628T130000Z", + "domain": "news.test", + "sourcecountry": "United Kingdom", + "language": "English", + }, + ] + } + + +def test_time_window_uses_startdatetime_when_no_timespan() -> None: + client = _mock_client(_articles_payload()) + config = GDELTConfig(enabled=True, query="ai") + scraper = GDELTScraper(config, client) + + asyncio.run(scraper.fetch(SINCE)) + + params = client.get.call_args.kwargs["params"] + assert params["startdatetime"] == SINCE.strftime("%Y%m%d%H%M%S") + assert "enddatetime" in params + assert "timespan" not in params + + +def test_time_window_uses_timespan_when_set() -> None: + client = _mock_client(_articles_payload()) + config = GDELTConfig(enabled=True, query="ai", timespan="24h") + scraper = GDELTScraper(config, client) + + asyncio.run(scraper.fetch(SINCE)) + + params = client.get.call_args.kwargs["params"] + assert params["timespan"] == "24h" + assert "startdatetime" not in params + assert "enddatetime" not in params + + +def test_language_and_country_appended_to_query() -> None: + client = _mock_client(_articles_payload()) + config = GDELTConfig( + enabled=True, query="ai", language="english", country="US" + ) + scraper = GDELTScraper(config, client) + + asyncio.run(scraper.fetch(SINCE)) + + params = client.get.call_args.kwargs["params"] + assert params["query"] == "ai sourcelang:english sourcecountry:US" + + +def test_parses_articles_into_content_items() -> None: + client = _mock_client(_articles_payload()) + config = GDELTConfig(enabled=True, query="ai") + scraper = GDELTScraper(config, client) + + items = asyncio.run(scraper.fetch(SINCE)) + + assert len(items) == 2 + first = items[0] + assert str(first.url) == "https://example.com/ai-1" + assert first.title == "AI breakthrough one" + assert first.published_at == datetime(2026, 6, 28, 12, 0, 0, tzinfo=timezone.utc) + assert first.metadata["domain"] == "example.com" + assert first.metadata["sourcecountry"] == "United States" + assert first.id.startswith("gdelt:article:") + + +def test_disabled_config_returns_empty() -> None: + client = _mock_client(_articles_payload()) + config = GDELTConfig(enabled=False, query="ai") + scraper = GDELTScraper(config, client) + + assert asyncio.run(scraper.fetch(SINCE)) == [] + + +def test_http_error_returns_empty() -> None: + client = AsyncMock() + client.get.side_effect = httpx.HTTPError("boom") + config = GDELTConfig(enabled=True, query="ai") + scraper = GDELTScraper(config, client) + + assert asyncio.run(scraper.fetch(SINCE)) == [] + + +def test_missing_articles_key_returns_empty() -> None: + client = _mock_client({"foo": "bar"}) + config = GDELTConfig(enabled=True, query="ai") + scraper = GDELTScraper(config, client) + + assert asyncio.run(scraper.fetch(SINCE)) == [] + + +def test_empty_query_returns_empty() -> None: + client = _mock_client(_articles_payload()) + config = GDELTConfig(enabled=True, query=" ") + scraper = GDELTScraper(config, client) + + assert asyncio.run(scraper.fetch(SINCE)) == [] + + +def test_bad_article_is_skipped_not_crashing() -> None: + payload = { + "articles": [ + { # missing url -> skipped + "title": "No URL", + "seendate": "20260628T120000Z", + "domain": "example.com", + }, + { # unparseable seendate -> skipped + "url": "https://example.com/bad-date", + "title": "Bad date", + "seendate": "not-a-date", + "domain": "example.com", + }, + { # valid -> kept + "url": "https://example.com/good", + "title": "Good one", + "seendate": "20260628T140000Z", + "domain": "example.com", + }, + ] + } + client = _mock_client(payload) + config = GDELTConfig(enabled=True, query="ai") + scraper = GDELTScraper(config, client) + + items = asyncio.run(scraper.fetch(SINCE)) + + assert len(items) == 1 + assert str(items[0].url) == "https://example.com/good" + + +def test_non_json_body_returns_empty() -> None: + response = MagicMock() + response.json.side_effect = ValueError("no json") + response.raise_for_status.return_value = None + client = AsyncMock() + client.get.return_value = response + config = GDELTConfig(enabled=True, query="ai") + scraper = GDELTScraper(config, client) + + assert asyncio.run(scraper.fetch(SINCE)) == [] diff --git a/tests/test_google_news.py b/tests/test_google_news.py new file mode 100644 index 0000000..225704d --- /dev/null +++ b/tests/test_google_news.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock + +import httpx + +from src.models import GoogleNewsConfig +from src.scrapers.google_news import GoogleNewsScraper + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _feed(items_xml: str) -> str: + return f""" + + Google News + {items_xml} + + """ + + +def _item( + title: str, + link: str, + pub: str = "Sat, 27 Jun 2026 12:00:00 GMT", + source: str | None = "Publisher", +) -> str: + source_tag = f"{source}" if source is not None else "" + return f""" + + {title} + {link} + {link} + {pub} + Summary text + {source_tag} + + """ + + +def _mock_client(text: str) -> AsyncMock: + response = MagicMock() + response.text = text + response.raise_for_status.return_value = None + client = AsyncMock() + client.get.return_value = response + return client + + +def test_short_window_uses_when_operator() -> None: + client = _mock_client(_feed(_item("Foo - Publisher", "https://example.com/a"))) + config = GoogleNewsConfig(enabled=True, query="ai") + scraper = GoogleNewsScraper(config, client) + + since = _now() - timedelta(hours=24) + asyncio.run(scraper.fetch(since)) + + q = client.get.call_args.kwargs["params"]["q"] + assert "when:" in q + assert q.endswith("h") + assert q.startswith("ai ") + + +def test_long_window_uses_after_operator() -> None: + client = _mock_client(_feed(_item("Foo - Publisher", "https://example.com/a"))) + config = GoogleNewsConfig(enabled=True, query="ai") + scraper = GoogleNewsScraper(config, client) + + since = _now() - timedelta(days=30) + asyncio.run(scraper.fetch(since)) + + q = client.get.call_args.kwargs["params"]["q"] + assert "after:" in q + assert "when:" not in q + + +def test_ceid_defaults_when_unset() -> None: + client = _mock_client(_feed(_item("Foo - Publisher", "https://example.com/a"))) + config = GoogleNewsConfig(enabled=True, query="ai", language="en", country="US") + scraper = GoogleNewsScraper(config, client) + + asyncio.run(scraper.fetch(_now() - timedelta(hours=6))) + + params = client.get.call_args.kwargs["params"] + assert params["ceid"] == "US:en" + assert params["hl"] == "en" + assert params["gl"] == "US" + + +def test_parses_feed_into_content_items() -> None: + items_xml = _item( + "Headline One - Publisher", + "https://example.com/a", + pub="Sat, 27 Jun 2026 12:00:00 GMT", + ) + _item( + "Headline Two - Other", + "https://example.com/b", + pub="Sat, 27 Jun 2026 13:00:00 GMT", + source="Other", + ) + client = _mock_client(_feed(items_xml)) + config = GoogleNewsConfig(enabled=True, query="ai") + scraper = GoogleNewsScraper(config, client) + + items = asyncio.run(scraper.fetch(_now() - timedelta(days=365))) + + assert len(items) == 2 + first = items[0] + assert first.title == "Headline One - Publisher" + assert str(first.url) == "https://example.com/a" + assert first.published_at == datetime(2026, 6, 27, 12, 0, 0, tzinfo=timezone.utc) + assert first.id.startswith("google_news:article:") + assert first.metadata["gn_query"] == "ai" + assert first.metadata["source_name"] == "Publisher" + + +def test_disabled_config_returns_empty() -> None: + client = _mock_client(_feed(_item("Foo", "https://example.com/a"))) + config = GoogleNewsConfig(enabled=False, query="ai") + scraper = GoogleNewsScraper(config, client) + + assert asyncio.run(scraper.fetch(_now())) == [] + + +def test_empty_query_returns_empty() -> None: + client = _mock_client(_feed(_item("Foo", "https://example.com/a"))) + config = GoogleNewsConfig(enabled=True, query=" ") + scraper = GoogleNewsScraper(config, client) + + assert asyncio.run(scraper.fetch(_now())) == [] + + +def test_http_error_returns_empty() -> None: + client = AsyncMock() + client.get.side_effect = httpx.HTTPError("boom") + config = GoogleNewsConfig(enabled=True, query="ai") + scraper = GoogleNewsScraper(config, client) + + assert asyncio.run(scraper.fetch(_now())) == [] + + +def test_empty_feed_returns_empty() -> None: + client = _mock_client(_feed("")) + config = GoogleNewsConfig(enabled=True, query="ai") + scraper = GoogleNewsScraper(config, client) + + assert asyncio.run(scraper.fetch(_now())) == [] + + +def test_entry_missing_link_or_title_is_skipped() -> None: + items_xml = ( + _item("", "https://example.com/no-title") # missing title + + "No linkSat, 27 Jun 2026 12:00:00 GMT" + + _item("Good - Publisher", "https://example.com/good") + ) + client = _mock_client(_feed(items_xml)) + config = GoogleNewsConfig(enabled=True, query="ai") + scraper = GoogleNewsScraper(config, client) + + items = asyncio.run(scraper.fetch(_now() - timedelta(days=365))) + + assert len(items) == 1 + assert str(items[0].url) == "https://example.com/good" + + +def test_max_results_cap() -> None: + items_xml = "".join( + _item(f"Item {i} - Pub", f"https://example.com/{i}") for i in range(5) + ) + client = _mock_client(_feed(items_xml)) + config = GoogleNewsConfig(enabled=True, query="ai", max_results=2) + scraper = GoogleNewsScraper(config, client) + + items = asyncio.run(scraper.fetch(_now() - timedelta(days=365))) + + assert len(items) == 2 diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py new file mode 100644 index 0000000..3ce3fb0 --- /dev/null +++ b/tests/test_mcp_adapter.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from src.mcp.horizon_adapter import ( + _load_mcp_secrets, + apply_source_filter, + load_config, + load_runtime, + resolve_config_path, + resolve_horizon_path, +) +from src.models import AIProvider, Config + + +def test_resolve_horizon_path_accepts_explicit_repo() -> None: + repo_root = Path(__file__).resolve().parents[1] + + assert resolve_horizon_path(str(repo_root)) == repo_root.resolve() + + +def test_resolve_config_path_defaults_to_repo_data_config(tmp_path: Path) -> None: + # Create a temporary config.json so resolve_config_path doesn't raise + config_path = tmp_path / "data" / "config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text("{}", encoding="utf-8") + + assert resolve_config_path(tmp_path) == config_path.resolve() + + +def test_load_mcp_secrets_loads_generic_env_keys(tmp_path: Path, monkeypatch) -> None: + secrets_path = tmp_path / "mcp.secrets.json" + secrets_path.write_text( + json.dumps( + { + "env": { + "ANTHROPIC_API_KEY": "sk-ant-test", + "CUSTOM_TOKEN": "token-123", + "lowercase": "ignored", + } + } + ), + encoding="utf-8", + ) + + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setenv("HORIZON_MCP_SECRETS_PATH", str(secrets_path)) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("CUSTOM_TOKEN", raising=False) + + _load_mcp_secrets(repo_root, override=False) + + assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-test" + assert os.environ["CUSTOM_TOKEN"] == "token-123" + assert "lowercase" not in os.environ + + +def test_load_config_expands_env_vars(tmp_path: Path, monkeypatch) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "ai": { + "provider": "openai", + "model": "test-model", + "api_key_env": "OPENAI_API_KEY", + "base_url": "${TEST_BASE_URL}/v1", + }, + "sources": {}, + "filtering": {}, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("TEST_BASE_URL", "https://api.example.com") + runtime = load_runtime(Path(__file__).resolve().parents[1]) + + config = load_config(runtime, config_path) + + assert config.ai.base_url == "https://api.example.com/v1" + + +def test_apply_source_filter_handles_twitter_and_openbb() -> None: + config = Config.model_validate( + { + "ai": { + "provider": AIProvider.OPENAI, + "model": "test-model", + "api_key_env": "OPENAI_API_KEY", + }, + "sources": { + "twitter": {"enabled": True, "users": ["openai"]}, + "openbb": { + "enabled": True, + "watchlists": [{"name": "ai", "symbols": ["NVDA"]}], + }, + }, + "filtering": {}, + } + ) + + filtered, chosen, unknown = apply_source_filter(config, ["twitter"]) + + assert chosen == ["twitter"] + assert unknown == [] + assert filtered.sources.twitter.enabled is True + assert filtered.sources.openbb.enabled is False + assert filtered.sources.openbb.watchlists == [] diff --git a/tests/test_mcp_errors.py b/tests/test_mcp_errors.py new file mode 100644 index 0000000..c4c3ec9 --- /dev/null +++ b/tests/test_mcp_errors.py @@ -0,0 +1,8 @@ +from src.mcp.errors import HorizonMcpError + + +def test_horizon_mcp_error_string_representation() -> None: + err = HorizonMcpError(code="E_TEST", message="boom", details={"k": "v"}) + + assert str(err) == "E_TEST: boom" + assert err.details == {"k": "v"} diff --git a/tests/test_mcp_run_store.py b/tests/test_mcp_run_store.py new file mode 100644 index 0000000..171c64c --- /dev/null +++ b/tests/test_mcp_run_store.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.mcp.run_store import RunStore + + +def test_create_run_writes_meta(tmp_path: Path) -> None: + store = RunStore(tmp_path) + + run_id = store.create_run() + meta = store.load_meta(run_id) + + assert run_id.startswith("run-") + assert meta["run_id"] == run_id + assert "created_at" in meta + + +def test_save_and_load_stage_items(tmp_path: Path) -> None: + store = RunStore(tmp_path) + run_id = store.create_run("run-fixed") + items = [{"title": "foo"}, {"title": "bar"}] + + path = store.save_items(run_id, "raw", items) + loaded = store.load_items(run_id, "raw") + + assert path.name == "raw_items.json" + assert loaded == items + assert store.has_stage(run_id, "raw") is True + + +def test_update_meta_sets_updated_at(tmp_path: Path) -> None: + store = RunStore(tmp_path) + run_id = store.create_run("run-meta") + + meta = store.update_meta(run_id, {"status": "done"}) + + assert meta["status"] == "done" + assert "updated_at" in meta + + +def test_save_and_load_summary(tmp_path: Path) -> None: + store = RunStore(tmp_path) + run_id = store.create_run("run-summary") + + saved = store.save_summary(run_id, "zh", "# 摘要") + content = store.load_summary(run_id, "zh") + + assert saved.name == "summary-zh.md" + assert content == "# 摘要" + + +def test_unsupported_stage_raises(tmp_path: Path) -> None: + store = RunStore(tmp_path) + run_id = store.create_run("run-invalid-stage") + + with pytest.raises(ValueError, match="Unsupported stage"): + store.save_items(run_id, "unknown", []) + + +def test_missing_run_raises(tmp_path: Path) -> None: + store = RunStore(tmp_path) + + with pytest.raises(FileNotFoundError, match="Run not found"): + store.run_dir("missing-run") + + +def test_rejects_path_traversal_run_id(tmp_path: Path) -> None: + store = RunStore(tmp_path / "runs") + + with pytest.raises(ValueError, match="Invalid run_id"): + store.create_run("../outside") + + assert not (tmp_path / "outside").exists() + + +def test_rejects_unsafe_summary_language(tmp_path: Path) -> None: + store = RunStore(tmp_path) + run_id = store.create_run("run-summary-safe") + + with pytest.raises(ValueError, match="Invalid summary language"): + store.save_summary(run_id, "../zh", "# unsafe") + + +def test_missing_artifact_raises(tmp_path: Path) -> None: + store = RunStore(tmp_path) + run_id = store.create_run("run-missing-file") + + with pytest.raises(FileNotFoundError, match="Artifact not found"): + store.read_json(run_id, "does-not-exist.json") + + +def test_list_runs_returns_desc_order(tmp_path: Path) -> None: + store = RunStore(tmp_path) + run1 = store.create_run("run-1") + store.update_meta(run1, {"seq": 1}) + run2 = store.create_run("run-2") + store.update_meta(run2, {"seq": 2}) + + runs = store.list_runs(limit=10) + + assert runs[0]["run_id"] == "run-2" + assert runs[1]["run_id"] == "run-1" diff --git a/tests/test_mcp_service_smoke.py b/tests/test_mcp_service_smoke.py new file mode 100644 index 0000000..f953dc4 --- /dev/null +++ b/tests/test_mcp_service_smoke.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace + +from src.models import ContentItem, SourceType +from src.mcp.server import hz_get_metrics +from src.mcp.service import HorizonPipelineService + + +def make_item(item_id: str, score: float | None = None) -> ContentItem: + item = ContentItem( + id=item_id, + source_type=SourceType.RSS, + title=f"Item {item_id}", + url=f"https://example.com/{item_id}", + content="content", + author="tester", + published_at=datetime.now(timezone.utc), + ) + item.ai_score = score + return item +def test_validate_config_smoke(tmp_path: Path) -> None: + repo_root = Path(__file__).resolve().parents[1] + config_path = tmp_path / "config.json" + config_path.write_text( + (repo_root / "data" / "config.example.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + + service = HorizonPipelineService(runs_root=tmp_path / "mcp-runs") + result = asyncio.run( + service.validate_config( + horizon_path=str(repo_root), + config_path=str(config_path), + check_env=False, + ) + ) + + assert result["config_path"] == str(config_path.resolve()) + assert result["enabled_sources"] + assert result["missing_env"] == [] + + +def test_get_effective_config_can_filter_sources(tmp_path: Path) -> None: + repo_root = Path(__file__).resolve().parents[1] + config_path = tmp_path / "config.json" + config_path.write_text( + (repo_root / "data" / "config.example.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + + service = HorizonPipelineService(runs_root=tmp_path / "mcp-runs") + result = service.get_effective_config( + horizon_path=str(repo_root), + config_path=str(config_path), + sources=["rss"], + ) + + assert result["selected_sources"] == ["rss"] + assert result["config"]["sources"]["github"] == [] + assert result["config"]["sources"]["rss"] + + +def test_metrics_tool_smoke() -> None: + result = hz_get_metrics() + + assert result["ok"] is True + assert result["tool"] == "hz_get_metrics" + + +def test_fetch_items_uses_public_orchestrator_api(tmp_path: Path, monkeypatch) -> None: + service = HorizonPipelineService(runs_root=tmp_path / "mcp-runs") + config_path = tmp_path / "config.json" + + monkeypatch.setattr( + service, + "_build_context", + lambda **kwargs: ( + SimpleNamespace( + horizon_path=tmp_path, + config_path=config_path, + runtime=SimpleNamespace(), + config=SimpleNamespace(), + ), + ["rss"], + [], + ), + ) + monkeypatch.setattr("src.mcp.service.make_storage", lambda runtime, config_path: object()) + + class FakeOrchestrator: + async def fetch_all_sources(self, since): # type: ignore[no-untyped-def] + return [make_item("item-1"), make_item("item-2")] + + def merge_cross_source_duplicates(self, items): # type: ignore[no-untyped-def] + return items[:1] + + monkeypatch.setattr( + "src.mcp.service.make_orchestrator", + lambda runtime, config, storage: FakeOrchestrator(), + ) + + result = asyncio.run(service.fetch_items(hours=6)) + + assert result["fetched"] == 1 + assert result["raw_before_merge"] == 2 + assert service.run_store.load_items(result["run_id"], "raw")[0]["id"] == "item-1" + + +def test_filter_items_uses_public_topic_dedup_api(tmp_path: Path, monkeypatch) -> None: + service = HorizonPipelineService(runs_root=tmp_path / "mcp-runs") + service.run_store.create_run("run-topic-dedup") + + monkeypatch.setattr( + service, + "_load_stage_items", + lambda **kwargs: ( + [make_item("item-1", score=9.0), make_item("item-2", score=8.0)], + SimpleNamespace( + runtime=SimpleNamespace(), + config_path=tmp_path / "config.json", + config=SimpleNamespace(filtering=SimpleNamespace(ai_score_threshold=7.0)), + ), + ), + ) + monkeypatch.setattr("src.mcp.service.make_storage", lambda runtime, config_path: object()) + + class FakeOrchestrator: + async def merge_topic_duplicates(self, items): # type: ignore[no-untyped-def] + return items[:1] + + monkeypatch.setattr( + "src.mcp.service.make_orchestrator", + lambda runtime, config, storage: FakeOrchestrator(), + ) + + result = asyncio.run(service.filter_items(run_id="run-topic-dedup", topic_dedup=True)) + + assert result["kept"] == 1 + assert result["removed_by_topic_dedup"] == 1 + assert service.run_store.load_items("run-topic-dedup", "filtered")[0]["id"] == "item-1" + + +def test_filter_items_applies_balanced_digest(tmp_path: Path, monkeypatch) -> None: + service = HorizonPipelineService(runs_root=tmp_path / "mcp-runs") + service.run_store.create_run("run-balanced") + filtering = SimpleNamespace( + ai_score_threshold=7.0, + max_items=1, + category_groups={}, + ) + + monkeypatch.setattr( + service, + "_load_stage_items", + lambda **kwargs: ( + [make_item("item-1", score=9.0), make_item("item-2", score=8.0)], + SimpleNamespace( + runtime=SimpleNamespace(), + config_path=tmp_path / "config.json", + config=SimpleNamespace(filtering=filtering), + ), + ), + ) + monkeypatch.setattr("src.mcp.service.make_storage", lambda runtime, config_path: object()) + + class FakeOrchestrator: + def apply_balanced_digest(self, items, log=True): # type: ignore[no-untyped-def] + assert log is False + return SimpleNamespace(items=items[:1], group_counts={"other": 1}) + + monkeypatch.setattr( + "src.mcp.service.make_orchestrator", + lambda runtime, config, storage: FakeOrchestrator(), + ) + + result = asyncio.run( + service.filter_items(run_id="run-balanced", topic_dedup=False) + ) + + assert result["kept"] == 1 + assert result["removed_by_balanced_digest"] == 1 + assert result["balanced_digest_enabled"] is True + assert result["group_counts"] == {"other": 1} diff --git a/tests/test_minimax_client.py b/tests/test_minimax_client.py new file mode 100644 index 0000000..9597bee --- /dev/null +++ b/tests/test_minimax_client.py @@ -0,0 +1,344 @@ +"""Tests for OpenAI-compatible AI client (covers MiniMax, Ali, DeepSeek).""" + +from __future__ import annotations + +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.ai.client import OpenAIClient, create_ai_client +from src.models import AIConfig, AIProvider + + +def _make_config(**overrides) -> AIConfig: + defaults = { + "provider": AIProvider.MINIMAX, + "model": "MiniMax-M3", + "api_key_env": "MINIMAX_API_KEY", + "temperature": 0.3, + "max_tokens": 4096, + } + defaults.update(overrides) + return AIConfig(**defaults) + + +def _make_ollama_config(**overrides) -> AIConfig: + defaults = { + "provider": AIProvider.OLLAMA, + "model": "llama3.1", + "api_key_env": "", + "temperature": 0.3, + "max_tokens": 4096, + } + defaults.update(overrides) + return AIConfig(**defaults) + + +class TestOpenAIClientInit: + def test_creates_instance_with_valid_config(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + client = OpenAIClient(_make_config()) + assert client.model == "MiniMax-M3" + assert client.max_tokens == 4096 + assert client.provider == "minimax" + + def test_raises_when_api_key_missing(self, monkeypatch): + monkeypatch.delenv("MINIMAX_API_KEY", raising=False) + with pytest.raises(ValueError, match="Missing API key"): + OpenAIClient(_make_config()) + + def test_rejects_literal_api_key_in_api_key_env_without_leaking_it(self, monkeypatch): + literal_key = "sk-test1234567890" + monkeypatch.delenv(literal_key, raising=False) + + with pytest.raises(ValueError) as exc: + OpenAIClient(_make_config(api_key_env=literal_key)) + + message = str(exc.value) + assert literal_key not in message + assert "api_key_env" in message + assert "environment variable name" in message + assert "MINIMAX_API_KEY" in message + + def test_does_not_echo_identifier_shaped_api_key(self, monkeypatch): + literal_key = "a2c9f1b4e6d7a3c0b5e8d1f9a4c2e6b8" + monkeypatch.delenv(literal_key, raising=False) + + with pytest.raises(ValueError) as exc: + OpenAIClient(_make_config(api_key_env=literal_key)) + + message = str(exc.value) + assert literal_key not in message + assert "api_key_env" in message + assert "MINIMAX_API_KEY" in message + + def test_uses_provider_default_base_url(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + client = OpenAIClient(_make_config()) + assert str(client.client.base_url).rstrip("/").endswith("api.minimax.io/v1") + + def test_uses_custom_base_url(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + client = OpenAIClient(_make_config(base_url="https://api.minimaxi.com/v1")) + assert "minimaxi.com" in str(client.client.base_url) + + def test_uses_default_base_url_for_ali(self, monkeypatch): + monkeypatch.setenv("ALI_API_KEY", "test-key") + client = OpenAIClient(_make_config( + provider=AIProvider.ALI, + api_key_env="ALI_API_KEY", + )) + assert "dashscope.aliyuncs.com" in str(client.client.base_url) + + def test_ollama_uses_localhost_default_base_url(self, monkeypatch): + monkeypatch.delenv("HORIZON_OLLAMA_BASE_URL", raising=False) + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) + monkeypatch.delenv("OLLAMA_HOST", raising=False) + + client = OpenAIClient(_make_ollama_config()) + + assert str(client.client.base_url).rstrip("/") == "http://localhost:11434/v1" + + def test_ollama_accepts_custom_base_url_without_v1(self, monkeypatch): + monkeypatch.delenv("HORIZON_OLLAMA_BASE_URL", raising=False) + + client = OpenAIClient(_make_ollama_config(base_url="http://192.168.1.10:11434")) + + assert str(client.client.base_url).rstrip("/") == "http://192.168.1.10:11434/v1" + + def test_ollama_does_not_duplicate_v1_in_custom_base_url(self, monkeypatch): + monkeypatch.delenv("HORIZON_OLLAMA_BASE_URL", raising=False) + + client = OpenAIClient(_make_ollama_config(base_url="https://ollama.example/v1/")) + + assert str(client.client.base_url).rstrip("/") == "https://ollama.example/v1" + + def test_ollama_uses_base_url_from_env(self, monkeypatch): + monkeypatch.setenv("HORIZON_OLLAMA_BASE_URL", "http://ollama.internal:11434") + + client = OpenAIClient(_make_ollama_config()) + + assert str(client.client.base_url).rstrip("/") == "http://ollama.internal:11434/v1" + + def test_ollama_config_base_url_overrides_env(self, monkeypatch): + monkeypatch.setenv("HORIZON_OLLAMA_BASE_URL", "http://env-host:11434") + + client = OpenAIClient(_make_ollama_config(base_url="http://config-host:11434")) + + assert str(client.client.base_url).rstrip("/") == "http://config-host:11434/v1" + + def test_ollama_host_env_without_scheme_is_supported(self, monkeypatch): + monkeypatch.delenv("HORIZON_OLLAMA_BASE_URL", raising=False) + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) + monkeypatch.setenv("OLLAMA_HOST", "nas.local:11434") + + client = OpenAIClient(_make_ollama_config()) + + assert str(client.client.base_url).rstrip("/") == "http://nas.local:11434/v1" + + +class TestOpenAIClientComplete: + def test_basic_completion(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + client = OpenAIClient(_make_config()) + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '{"score": 8}' + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_response + result = asyncio.run(client.complete(system="test", user="hello")) + + assert result == '{"score": 8}' + call_kwargs = mock_create.call_args[1] + assert call_kwargs["model"] == "MiniMax-M3" + # response_format should NOT be present (MiniMax doesn't support it) + assert "response_format" not in call_kwargs + + def test_temperature_zero_clamped_for_minimax(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + client = OpenAIClient(_make_config()) + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "ok" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_response + asyncio.run(client.complete(system="test", user="hello", temperature=0.0)) + + call_kwargs = mock_create.call_args[1] + assert call_kwargs["temperature"] > 0 + + def test_response_format_present_for_openai(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + client = OpenAIClient(_make_config( + provider=AIProvider.OPENAI, + api_key_env="OPENAI_API_KEY", + )) + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '{"score": 8}' + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_response + asyncio.run(client.complete(system="test", user="hello")) + + call_kwargs = mock_create.call_args[1] + assert call_kwargs.get("response_format") == {"type": "json_object"} + + +class TestTemperatureFallback: + """Retry-without-temperature path for models that deprecated temperature. + + Triggered by Claude Opus 4.7 on Bedrock Converse and any OpenAI-compatible + endpoint that rejects `temperature` with a 4xx error message. + """ + + @staticmethod + def _make_response(text: str = "{}") -> MagicMock: + resp = MagicMock() + resp.choices = [MagicMock()] + resp.choices[0].message.content = text + resp.usage.prompt_tokens = 1 + resp.usage.completion_tokens = 1 + return resp + + def test_sends_temperature_by_default(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + client = OpenAIClient(_make_config( + provider=AIProvider.OPENAI, + api_key_env="OPENAI_API_KEY", + )) + + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = self._make_response() + asyncio.run(client.complete(system="s", user="u")) + + assert "temperature" in mock_create.call_args[1] + assert client._supports_temperature is True + + def test_retries_without_temperature_on_deprecated_error(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + client = OpenAIClient(_make_config( + provider=AIProvider.OPENAI, + api_key_env="OPENAI_API_KEY", + )) + + first_error = Exception( + "400 Bad Request: `temperature` is deprecated for this model." + ) + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [first_error, self._make_response("ok")] + result = asyncio.run(client.complete(system="s", user="u")) + + assert result == "ok" + assert mock_create.call_count == 2 + first_kwargs = mock_create.call_args_list[0][1] + retry_kwargs = mock_create.call_args_list[1][1] + assert "temperature" in first_kwargs + assert "temperature" not in retry_kwargs + assert client._supports_temperature is False + + def test_does_not_retry_for_unrelated_error(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + client = OpenAIClient(_make_config( + provider=AIProvider.OPENAI, + api_key_env="OPENAI_API_KEY", + )) + + boom = Exception("500 Internal Server Error") + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = boom + with pytest.raises(Exception, match="Internal Server Error"): + asyncio.run(client.complete(system="s", user="u")) + + assert mock_create.call_count == 1 + assert client._supports_temperature is True + + def test_subsequent_calls_skip_temperature_after_fallback(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + client = OpenAIClient(_make_config( + provider=AIProvider.OPENAI, + api_key_env="OPENAI_API_KEY", + )) + + client._supports_temperature = False + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = self._make_response() + asyncio.run(client.complete(system="s", user="u")) + + assert "temperature" not in mock_create.call_args[1] + assert mock_create.call_count == 1 + + @pytest.mark.parametrize("msg", [ + "`temperature` is deprecated for this model", + "The model does not support temperature parameter", + "Unsupported parameter: temperature", + ]) + def test_detects_various_temperature_error_messages( + self, monkeypatch, msg + ): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + client = OpenAIClient(_make_config( + provider=AIProvider.OPENAI, + api_key_env="OPENAI_API_KEY", + )) + + with patch.object( + client.client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [Exception(msg), self._make_response("ok")] + result = asyncio.run(client.complete(system="s", user="u")) + + assert result == "ok" + assert mock_create.call_count == 2 + + +class TestFactoryFunction: + def test_creates_openai_client_for_minimax(self, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + config = _make_config() + client = create_ai_client(config) + assert isinstance(client, OpenAIClient) + assert client.provider == "minimax" + + def test_creates_openai_client_for_deepseek(self, monkeypatch): + monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key") + config = _make_config( + provider=AIProvider.DEEPSEEK, + api_key_env="DEEPSEEK_API_KEY", + ) + client = create_ai_client(config) + assert isinstance(client, OpenAIClient) + assert client.provider == "deepseek" + + def test_minimax_provider_enum(self): + assert AIProvider.MINIMAX.value == "minimax" + + def test_deepseek_provider_enum(self): + assert AIProvider.DEEPSEEK.value == "deepseek" diff --git a/tests/test_openbb_scraper.py b/tests/test_openbb_scraper.py new file mode 100644 index 0000000..4466529 --- /dev/null +++ b/tests/test_openbb_scraper.py @@ -0,0 +1,297 @@ +"""Tests for the OpenBB scraper. + +The tests do not require the ``openbb`` package to be installed. We +verify that: + +* The scraper short-circuits to [] when the SDK is missing or disabled. +* It correctly maps OpenBB news records into ContentItems. +* It filters out items older than ``since`` and drops duplicates across + watchlists by URL. +* Malformed OpenBB rows (missing url/date/title) are skipped instead of + crashing the run. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +import httpx +import pytest + +from src.models import OpenBBConfig, OpenBBWatchlist, SourceType +from src.scrapers.openbb import OpenBBScraper + + +def _cfg(**overrides) -> OpenBBConfig: + base = { + "enabled": True, + "watchlists": [ + OpenBBWatchlist( + name="megacaps", + symbols=["AAPL", "NVDA"], + provider="yfinance", + fetch_limit=5, + ) + ], + } + base.update(overrides) + return OpenBBConfig(**base) + + +def _make_scraper(config: OpenBBConfig, obb: object) -> OpenBBScraper: + client = httpx.AsyncClient() + scraper = OpenBBScraper(config, client) + scraper._obb = obb + return scraper + + +_SENTINEL = object() + + +def _news_row( + *, + title: str = "NVDA earnings beat", + url: str = "https://example.com/nvda-earnings", + date: object = _SENTINEL, + author: str | None = "Reporter", + body: str | None = "Body", + symbols: object = "NVDA", +) -> SimpleNamespace: + if date is _SENTINEL: + date = datetime.now(timezone.utc) + return SimpleNamespace( + title=title, + url=url, + date=date, + author=author, + body=body, + excerpt=None, + symbols=symbols, + ) + + +class TestFetchGuards: + def test_returns_empty_when_obb_not_installed(self): + scraper = _make_scraper(_cfg(), obb=None) + since = datetime.now(timezone.utc) - timedelta(days=1) + result = asyncio.run(scraper.fetch(since)) + assert result == [] + + def test_returns_empty_when_disabled(self): + obb = MagicMock() + scraper = _make_scraper(_cfg(enabled=False), obb=obb) + since = datetime.now(timezone.utc) - timedelta(days=1) + assert asyncio.run(scraper.fetch(since)) == [] + obb.news.company.assert_not_called() + + def test_skips_disabled_watchlist(self): + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace(results=[]) + cfg = OpenBBConfig( + enabled=True, + watchlists=[ + OpenBBWatchlist(name="off", symbols=["AAPL"], enabled=False), + ], + ) + scraper = _make_scraper(cfg, obb=obb) + since = datetime.now(timezone.utc) - timedelta(days=1) + assert asyncio.run(scraper.fetch(since)) == [] + obb.news.company.assert_not_called() + + def test_skips_watchlist_with_no_symbols(self): + obb = MagicMock() + cfg = OpenBBConfig( + enabled=True, + watchlists=[OpenBBWatchlist(name="empty", symbols=[])], + ) + scraper = _make_scraper(cfg, obb=obb) + since = datetime.now(timezone.utc) - timedelta(days=1) + assert asyncio.run(scraper.fetch(since)) == [] + obb.news.company.assert_not_called() + + +class TestMapping: + def test_maps_single_news_row_into_content_item(self): + now = datetime.now(timezone.utc) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[_news_row(date=now, symbols="NVDA,AAPL")] + ) + scraper = _make_scraper(_cfg(), obb=obb) + since = now - timedelta(hours=1) + + result = asyncio.run(scraper.fetch(since)) + + assert len(result) == 1 + item = result[0] + assert item.source_type == SourceType.OPENBB + assert item.title == "NVDA earnings beat" + assert item.content == "Body" + assert item.metadata["watchlist"] == "megacaps" + assert item.metadata["provider"] == "yfinance" + assert item.metadata["symbols"] == ["NVDA", "AAPL"] + assert item.id.startswith("openbb:news:") + + def test_passes_comma_joined_symbols_to_openbb(self): + now = datetime.now(timezone.utc) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace(results=[]) + scraper = _make_scraper(_cfg(), obb=obb) + + asyncio.run(scraper.fetch(now - timedelta(hours=1))) + + call_kwargs = obb.news.company.call_args.kwargs + assert call_kwargs["symbol"] == "AAPL,NVDA" + assert call_kwargs["provider"] == "yfinance" + assert call_kwargs["limit"] == 5 + + def test_falls_back_to_watchlist_symbols_when_row_has_none(self): + now = datetime.now(timezone.utc) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[_news_row(date=now, symbols="")] + ) + scraper = _make_scraper(_cfg(), obb=obb) + result = asyncio.run(scraper.fetch(now - timedelta(hours=1))) + assert result[0].metadata["symbols"] == ["AAPL", "NVDA"] + + def test_parses_iso_string_date(self): + now = datetime.now(timezone.utc) + iso = now.isoformat().replace("+00:00", "Z") + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[_news_row(date=iso)] + ) + scraper = _make_scraper(_cfg(), obb=obb) + result = asyncio.run(scraper.fetch(now - timedelta(hours=1))) + assert len(result) == 1 + assert result[0].published_at.tzinfo is not None + + +class TestFiltering: + def test_drops_items_older_than_since(self): + now = datetime.now(timezone.utc) + old = now - timedelta(days=3) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[_news_row(date=old)] + ) + scraper = _make_scraper(_cfg(), obb=obb) + result = asyncio.run(scraper.fetch(now - timedelta(hours=1))) + assert result == [] + + def test_deduplicates_across_watchlists_by_url(self): + now = datetime.now(timezone.utc) + shared_url = "https://finance.yahoo.com/articles/abc" + cfg = OpenBBConfig( + enabled=True, + watchlists=[ + OpenBBWatchlist(name="wl1", symbols=["AAPL"]), + OpenBBWatchlist(name="wl2", symbols=["MSFT"]), + ], + ) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[_news_row(url=shared_url, date=now)] + ) + scraper = _make_scraper(cfg, obb=obb) + result = asyncio.run(scraper.fetch(now - timedelta(hours=1))) + assert len(result) == 1 + assert result[0].metadata["watchlist"] == "wl1" + assert obb.news.company.call_count == 2 + + def test_handles_empty_results(self): + now = datetime.now(timezone.utc) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace(results=[]) + scraper = _make_scraper(_cfg(), obb=obb) + assert asyncio.run(scraper.fetch(now - timedelta(hours=1))) == [] + + +class TestResilience: + def test_malformed_row_missing_url_is_skipped(self): + now = datetime.now(timezone.utc) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[ + _news_row(url="", date=now), + _news_row(date=now), + ] + ) + scraper = _make_scraper(_cfg(), obb=obb) + result = asyncio.run(scraper.fetch(now - timedelta(hours=1))) + assert len(result) == 1 + + def test_malformed_row_missing_title_is_skipped(self): + now = datetime.now(timezone.utc) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[_news_row(title="", date=now)] + ) + scraper = _make_scraper(_cfg(), obb=obb) + assert asyncio.run(scraper.fetch(now - timedelta(hours=1))) == [] + + def test_malformed_row_missing_date_is_skipped(self): + now = datetime.now(timezone.utc) + obb = MagicMock() + obb.news.company.return_value = SimpleNamespace( + results=[_news_row(date=None)] + ) + scraper = _make_scraper(_cfg(), obb=obb) + assert asyncio.run(scraper.fetch(now - timedelta(hours=1))) == [] + + def test_watchlist_exception_does_not_block_others(self): + now = datetime.now(timezone.utc) + cfg = OpenBBConfig( + enabled=True, + watchlists=[ + OpenBBWatchlist(name="boom", symbols=["AAPL"]), + OpenBBWatchlist(name="ok", symbols=["MSFT"]), + ], + ) + obb = MagicMock() + obb.news.company.side_effect = [ + RuntimeError("upstream 500"), + SimpleNamespace(results=[_news_row(date=now)]), + ] + scraper = _make_scraper(cfg, obb=obb) + result = asyncio.run(scraper.fetch(now - timedelta(hours=1))) + assert len(result) == 1 + assert result[0].metadata["watchlist"] == "ok" + + +class TestStatic: + @pytest.mark.parametrize("value,expected", [ + ("AAPL,MSFT", ["AAPL", "MSFT"]), + ("aapl, aapl ,msft", ["AAPL", "MSFT"]), + (["AAPL", "MSFT"], ["AAPL", "MSFT"]), + (None, []), + ("", []), + (12345, []), + ]) + def test_parse_symbols(self, value, expected): + assert OpenBBScraper._parse_symbols(value) == expected + + def test_ensure_utc_naive(self): + naive = datetime(2025, 1, 1, 12, 0, 0) + out = OpenBBScraper._ensure_utc(naive) + assert out.tzinfo is not None + + def test_ensure_utc_other_tz(self): + other = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=8))) + out = OpenBBScraper._ensure_utc(other) + assert out.tzinfo == timezone.utc + assert out.hour == 4 + + def test_coerce_datetime_handles_bogus(self): + assert OpenBBScraper._coerce_datetime("not-a-date") is None + assert OpenBBScraper._coerce_datetime(42) is None + assert OpenBBScraper._coerce_datetime(None) is None + + def test_coerce_url_handles_bogus(self): + assert OpenBBScraper._coerce_url(None) is None + assert OpenBBScraper._coerce_url(" ") is None + assert OpenBBScraper._coerce_url("http://x") == "http://x" diff --git a/tests/test_reddit.py b/tests/test_reddit.py new file mode 100644 index 0000000..84d446d --- /dev/null +++ b/tests/test_reddit.py @@ -0,0 +1,291 @@ +import asyncio +from datetime import datetime, timedelta, timezone + +import httpx + +from src.models import RedditConfig, RedditSubredditConfig +from src.scrapers.reddit import REDDIT_HEADERS, RedditScraper + + +def _make_config(fetch_comments: int = 1) -> RedditConfig: + return RedditConfig( + enabled=True, + subreddits=[ + RedditSubredditConfig( + subreddit="LocalLLaMA", + enabled=True, + sort="hot", + fetch_limit=1, + min_score=1, + ) + ], + users=[], + fetch_comments=fetch_comments, + ) + + +def _make_two_subreddit_config(fetch_comments: int = 0) -> RedditConfig: + return RedditConfig( + enabled=True, + subreddits=[ + RedditSubredditConfig( + subreddit="LocalLLaMA", + enabled=True, + sort="hot", + fetch_limit=1, + min_score=1, + ), + RedditSubredditConfig( + subreddit="MachineLearning", + enabled=True, + sort="hot", + fetch_limit=1, + min_score=1, + ), + ], + users=[], + fetch_comments=fetch_comments, + ) + + +def _listing_payload() -> dict: + now = datetime.now(timezone.utc) + return { + "data": { + "children": [ + { + "kind": "t3", + "data": { + "id": "abc123", + "title": "Test post", + "is_self": True, + "subreddit": "LocalLLaMA", + "permalink": "/r/LocalLLaMA/comments/abc123/test_post/", + "author": "tester", + "created_utc": now.timestamp(), + "score": 42, + "upvote_ratio": 0.97, + "num_comments": 5, + "selftext": "post body", + }, + } + ] + } + } + + +def _old_listing_html() -> str: + timestamp_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + return f""" + + + """ + + +def _old_listing_html_for(subreddit: str, post_id: str) -> str: + timestamp_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + return f""" + + + """ + + +def _old_comments_html() -> str: + return """ + +
    +
    first old comment
    +
    +
    +
    second old comment
    +
    + """ + + +def test_reddit_fetch_uses_browser_like_headers(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, text=_old_listing_html()) + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + scraper = RedditScraper(_make_config(fetch_comments=0), client) + + asyncio.run(scraper.fetch(datetime.now(timezone.utc) - timedelta(hours=1))) + asyncio.run(client.aclose()) + + assert len(requests) == 1 + assert requests[0].url.host == "old.reddit.com" + assert requests[0].headers["user-agent"] == REDDIT_HEADERS["User-Agent"] + assert requests[0].headers["accept-language"] == REDDIT_HEADERS["Accept-Language"] + assert requests[0].headers["referer"] == REDDIT_HEADERS["Referer"] + + +def test_reddit_comment_403_degrades_to_post_without_comments(): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "old.reddit.com": + return httpx.Response(500, text="old reddit unavailable") + if request.url.path.endswith("/hot.json"): + return httpx.Response(200, json=_listing_payload()) + if "/comments/" in request.url.path: + return httpx.Response(403, text="blocked") + raise AssertionError(f"unexpected url: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + scraper = RedditScraper(_make_config(fetch_comments=3), client) + + items = asyncio.run(scraper.fetch(datetime.now(timezone.utc) - timedelta(hours=1))) + asyncio.run(client.aclose()) + + assert len(items) == 1 + assert items[0].title == "Test post" + assert "Top Comments" not in (items[0].content or "") + + +def test_reddit_listing_uses_old_reddit_first(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "old.reddit.com" and request.url.path.endswith("/hot/"): + return httpx.Response(200, text=_old_listing_html()) + raise AssertionError(f"unexpected url: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + scraper = RedditScraper(_make_config(fetch_comments=0), client) + + items = asyncio.run(scraper.fetch(datetime.now(timezone.utc) - timedelta(hours=1))) + asyncio.run(client.aclose()) + + assert [str(request.url.host) for request in requests] == ["old.reddit.com"] + assert len(items) == 1 + assert items[0].title == "Old HTML post" + assert items[0].content == "old body" + assert items[0].author == "old_author" + assert items[0].metadata["score"] == 42 + assert items[0].metadata["num_comments"] == 7 + + +def test_reddit_listing_old_failure_falls_back_to_json_then_rss(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "old.reddit.com": + return httpx.Response(500, text="old reddit unavailable") + if request.url.path.endswith("/hot.json"): + return httpx.Response(403, text="blocked") + if request.url.path.endswith("/hot/.rss"): + return httpx.Response( + 200, + text=""" + + + + t3_rss123 + RSS fallback post + rss_author + + 2030-01-01T00:00:00+00:00 + <p>RSS body</p> + + + """, + ) + raise AssertionError(f"unexpected url: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + scraper = RedditScraper(_make_config(fetch_comments=3), client) + + items = asyncio.run(scraper.fetch(datetime(2029, 12, 31, tzinfo=timezone.utc))) + asyncio.run(client.aclose()) + + assert [request.url.path for request in requests] == [ + "/r/LocalLLaMA/hot/", + "/r/LocalLLaMA/hot.json", + "/r/LocalLLaMA/hot/.rss", + ] + assert len(items) == 1 + assert items[0].title == "RSS fallback post" + assert items[0].content == "RSS body" + assert items[0].author == "rss_author" + assert items[0].metadata["subreddit"] == "LocalLLaMA" + assert items[0].metadata["fallback"] == "rss" + + +def test_reddit_comments_use_old_reddit_first(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.host == "old.reddit.com" and request.url.path.endswith("/hot/"): + return httpx.Response(200, text=_old_listing_html()) + if request.url.host == "old.reddit.com" and "/comments/old123/" in request.url.path: + return httpx.Response(200, text=_old_comments_html()) + raise AssertionError(f"unexpected url: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + scraper = RedditScraper(_make_config(fetch_comments=2), client) + + items = asyncio.run(scraper.fetch(datetime.now(timezone.utc) - timedelta(hours=1))) + asyncio.run(client.aclose()) + + assert [str(request.url.host) for request in requests] == [ + "old.reddit.com", + "old.reddit.com", + ] + assert len(items) == 1 + content = items[0].content or "" + assert "[alice (10 pts)]: first old comment" in content + assert "[bob (2 pts)]: second old comment" in content + + +def test_reddit_subreddits_are_fetched_sequentially(): + requests = [] + local_done = asyncio.Event() + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request.url.path) + if "/r/LocalLLaMA/" in request.url.path: + await asyncio.sleep(0) + local_done.set() + return httpx.Response(200, text=_old_listing_html_for("LocalLLaMA", "local123")) + if "/r/MachineLearning/" in request.url.path: + assert local_done.is_set() + return httpx.Response( + 200, text=_old_listing_html_for("MachineLearning", "ml123") + ) + raise AssertionError(f"unexpected url: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + scraper = RedditScraper(_make_two_subreddit_config(fetch_comments=0), client) + + items = asyncio.run(scraper.fetch(datetime.now(timezone.utc) - timedelta(hours=1))) + asyncio.run(client.aclose()) + + assert requests == ["/r/LocalLLaMA/hot/", "/r/MachineLearning/hot/"] + assert [item.metadata["subreddit"] for item in items] == [ + "LocalLLaMA", + "MachineLearning", + ] diff --git a/tests/test_rss.py b/tests/test_rss.py new file mode 100644 index 0000000..39bfbe6 --- /dev/null +++ b/tests/test_rss.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +from src.models import RSSSourceConfig +from src.scrapers.rss import RSSScraper + +_FEED = """ +Test + + entry-1 + Item 1 + https://example.com/item-1 + Fri, 24 Apr 2026 12:00:00 GMT + Short summary from feed. + + +""" +_SINCE = datetime(2026, 4, 24, 0, 0, tzinfo=timezone.utc) + + +def _make_feed_client(feed_text: str) -> AsyncMock: + response = MagicMock() + response.text = feed_text + response.raise_for_status.return_value = None + client = AsyncMock() + client.get.return_value = response + return client + + +def test_rss_ids_are_deterministic() -> None: + client = _make_feed_client(_FEED) + source = RSSSourceConfig(name="Test", url="https://example.com/feed.xml") + scraper = RSSScraper([source], client) + + first = asyncio.run(scraper.fetch(_SINCE))[0].id + second = asyncio.run(scraper.fetch(_SINCE))[0].id + + assert first == second + assert first == "rss:example.com_feed.xml:5e2d5d1e58e94d76" + + +def _make_registry(name: str, extractor): + registry = MagicMock() + registry.get.side_effect = lambda n: extractor if n == name else None + return registry + + +def test_content_extractor_replaces_feed_content() -> None: + client = _make_feed_client(_FEED) + extractor = AsyncMock() + extractor.extract.return_value = "Full article text from extractor." + + source = RSSSourceConfig( + name="Test", url="https://example.com/feed.xml", content_extractor="my-ext" + ) + scraper = RSSScraper([source], client, extractors=_make_registry("my-ext", extractor)) + items = asyncio.run(scraper.fetch(_SINCE)) + + assert len(items) == 1 + assert items[0].content == "Full article text from extractor." + extractor.extract.assert_awaited_once_with("https://example.com/item-1", client) + + +def test_content_extractor_falls_back_on_none() -> None: + client = _make_feed_client(_FEED) + extractor = AsyncMock() + extractor.extract.return_value = None # extraction failed + + source = RSSSourceConfig( + name="Test", url="https://example.com/feed.xml", content_extractor="my-ext" + ) + scraper = RSSScraper([source], client, extractors=_make_registry("my-ext", extractor)) + items = asyncio.run(scraper.fetch(_SINCE)) + + assert len(items) == 1 + assert items[0].content == "Short summary from feed." + + +def test_unknown_extractor_name_ignored() -> None: + client = _make_feed_client(_FEED) + source = RSSSourceConfig( + name="Test", url="https://example.com/feed.xml", content_extractor="nonexistent" + ) + scraper = RSSScraper([source], client, extractors=_make_registry("other", AsyncMock())) + items = asyncio.run(scraper.fetch(_SINCE)) + + assert len(items) == 1 + assert items[0].content == "Short summary from feed." diff --git a/tests/test_setup_wizard.py b/tests/test_setup_wizard.py new file mode 100644 index 0000000..c5aa557 --- /dev/null +++ b/tests/test_setup_wizard.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from src.models import AIConfig, AIProvider +from src.setup import wizard + + +def test_configure_ai_allows_ollama_without_api_key(monkeypatch): + answers = iter( + [ + "ollama", + "llama3.2", + "http://nas.local:11434", + "", + "zh,en", + ] + ) + + monkeypatch.setattr(wizard.Prompt, "ask", lambda *args, **kwargs: next(answers)) + monkeypatch.setattr(wizard.console, "print", lambda *args, **kwargs: None) + + config = wizard.configure_ai() + + assert config == AIConfig( + provider=AIProvider.OLLAMA, + model="llama3.2", + base_url="http://nas.local:11434", + api_key_env="", + temperature=0.3, + max_tokens=8192, + languages=["zh", "en"], + ) + + +def test_ai_recommendations_available_for_ollama_without_api_key(): + config = AIConfig( + provider=AIProvider.OLLAMA, + model="llama3.1", + api_key_env="", + ) + + assert wizard._ai_recommendations_available(config) is True + + +def test_ai_recommendations_require_api_key_for_cloud_provider(monkeypatch): + config = AIConfig( + provider=AIProvider.OPENAI, + model="gpt-4", + api_key_env="OPENAI_API_KEY", + ) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + assert wizard._ai_recommendations_available(config) is False + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + assert wizard._ai_recommendations_available(config) is True diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..0812e65 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,128 @@ +import json +import pytest +from pathlib import Path +from src.storage.manager import StorageManager, ConfigError, _expand_env_vars + +def test_load_config_missing_file(tmp_path): + storage = StorageManager(data_dir=str(tmp_path)) + with pytest.raises(FileNotFoundError): + storage.load_config() + +def test_load_config_invalid_json(tmp_path): + config_path = tmp_path / "config.json" + config_path.write_text("invalid json", encoding="utf-8") + + storage = StorageManager(data_dir=str(tmp_path)) + with pytest.raises(ConfigError) as excinfo: + storage.load_config() + assert "Invalid JSON in configuration file" in str(excinfo.value) + assert str(config_path) in str(excinfo.value) + +def test_load_config_validation_failure(tmp_path): + config_path = tmp_path / "config.json" + # Missing required 'ai' and 'sources' fields + config_path.write_text(json.dumps({"version": "1.0"}), encoding="utf-8") + + storage = StorageManager(data_dir=str(tmp_path)) + with pytest.raises(ConfigError) as excinfo: + storage.load_config() + assert "Configuration validation failed" in str(excinfo.value) + assert str(config_path) in str(excinfo.value) + +def test_load_config_success(tmp_path): + config_path = tmp_path / "config.json" + config_data = { + "version": "1.0", + "ai": { + "provider": "anthropic", + "model": "claude-3-sonnet", + "api_key_env": "ANTHROPIC_API_KEY" + }, + "sources": { + "hackernews": {"enabled": True} + }, + "filtering": { + "ai_score_threshold": 7.0, + "time_window_hours": 24 + } + } + config_path.write_text(json.dumps(config_data), encoding="utf-8") + + storage = StorageManager(data_dir=str(tmp_path)) + config = storage.load_config() + assert config.version == "1.0" + assert config.ai.provider == "anthropic" + + +class TestExpandEnvVars: + """Recursive ${VAR} expansion on config dicts/lists/strings.""" + + def test_expands_simple_reference(self, monkeypatch): + monkeypatch.setenv("FOO", "bar") + assert _expand_env_vars("prefix-${FOO}-suffix") == "prefix-bar-suffix" + + def test_expands_multiple_references_in_one_string(self, monkeypatch): + monkeypatch.setenv("A", "1") + monkeypatch.setenv("B", "2") + assert _expand_env_vars("${A}/${B}") == "1/2" + + def test_leaves_unset_var_as_placeholder(self, monkeypatch): + monkeypatch.delenv("MISSING", raising=False) + assert _expand_env_vars("${MISSING}") == "${MISSING}" + + def test_ignores_non_matching_patterns(self): + assert _expand_env_vars("no braces here") == "no braces here" + assert _expand_env_vars("$FOO without braces") == "$FOO without braces" + assert _expand_env_vars("${123INVALID}") == "${123INVALID}" + + def test_recurses_into_dict(self, monkeypatch): + monkeypatch.setenv("HOST", "api.example.com") + result = _expand_env_vars({"url": "https://${HOST}/v1", "port": 443}) + assert result == {"url": "https://api.example.com/v1", "port": 443} + + def test_recurses_into_list(self, monkeypatch): + monkeypatch.setenv("X", "hi") + assert _expand_env_vars(["${X}", "plain", 7]) == ["hi", "plain", 7] + + def test_preserves_non_string_leaves(self): + assert _expand_env_vars(42) == 42 + assert _expand_env_vars(3.14) == 3.14 + assert _expand_env_vars(True) is True + assert _expand_env_vars(None) is None + + def test_deeply_nested(self, monkeypatch): + monkeypatch.setenv("TOKEN", "secret") + value = { + "a": [ + {"b": "Bearer ${TOKEN}"}, + {"b": ["${TOKEN}", 1]}, + ], + } + out = _expand_env_vars(value) + assert out["a"][0]["b"] == "Bearer secret" + assert out["a"][1]["b"] == ["secret", 1] + + +def test_load_config_expands_env_vars_in_ai_base_url(tmp_path, monkeypatch): + """Integration: proves base_url is env-expandable end-to-end. + + This is exactly the use case that keeps private/tenant endpoint + URLs out of version control. + """ + monkeypatch.setenv("HORIZON_AI_BASE_URL", "https://private-proxy.example/v1") + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "version": "1.0", + "ai": { + "provider": "openai", + "model": "gpt-4o", + "api_key_env": "OPENAI_API_KEY", + "base_url": "${HORIZON_AI_BASE_URL}", + }, + "sources": {"hackernews": {"enabled": True}}, + "filtering": {"ai_score_threshold": 6.0, "time_window_hours": 24}, + }), encoding="utf-8") + + storage = StorageManager(data_dir=str(tmp_path)) + config = storage.load_config() + assert config.ai.base_url == "https://private-proxy.example/v1" diff --git a/tests/test_summarizer.py b/tests/test_summarizer.py new file mode 100644 index 0000000..97eeb12 --- /dev/null +++ b/tests/test_summarizer.py @@ -0,0 +1,140 @@ +"""Unit tests for daily summary rendering.""" + +import asyncio +from datetime import datetime, timezone + +from src.ai.summarizer import DailySummarizer +from src.models import ContentItem, SourceType + + +def _run_async(coro): + return asyncio.run(coro) + + +def _make_item(idx: int) -> ContentItem: + item = ContentItem( + id=f"rss:item-{idx}", + source_type=SourceType.RSS, + title=f"Important Item {idx}", + url=f"https://example.com/items/{idx}", + content="content", + author="tester", + published_at=datetime(2026, 4, 25, 8, 0, tzinfo=timezone.utc), + ) + item.ai_score = 8.0 + item.ai_summary = f"Summary for item {idx}." + item.ai_tags = ["AI", "News"] + return item + + +def test_generate_webhook_overview_lists_items_without_full_details(): + summarizer = DailySummarizer() + items = [_make_item(1), _make_item(2)] + + result = summarizer.generate_webhook_overview( + items, + date="2026-04-25", + total_fetched=10, + language="en", + ) + + assert "Selected 2 important items from 10 fetched items" in result + assert "1. [Important Item 1](https://example.com/items/1)" in result + assert "2. [Important Item 2](https://example.com/items/2)" in result + assert "Summary for item 1." not in result + + +def test_generate_webhook_item_renders_single_item_detail(): + summarizer = DailySummarizer() + + result = summarizer.generate_webhook_item( + _make_item(1), + language="en", + index=1, + total=2, + ) + + assert result.startswith("Item 1/2") + assert "## [Important Item 1](https://example.com/items/1)" in result + assert "Summary for item 1." in result + assert "**Tags**: `#AI`, `#News`" in result + + +def test_generate_webhook_item_includes_discussion_link_when_distinct(): + summarizer = DailySummarizer() + item = _make_item(1) + item.metadata["discussion_url"] = "https://news.ycombinator.com/item?id=1" + + result = summarizer.generate_webhook_item( + item, + language="en", + index=1, + total=1, + ) + + assert "tester · Apr 25, 08:00 · [Discussion](https://news.ycombinator.com/item?id=1)" in result + + +def test_generate_webhook_item_omits_discussion_link_when_same_as_item_url(): + summarizer = DailySummarizer() + item = _make_item(1) + item.metadata["discussion_url"] = item.url + + result = summarizer.generate_webhook_item( + item, + language="en", + index=1, + total=1, + ) + + assert "[Discussion](https://example.com/items/1)" not in result + + +def test_generate_webhook_item_uses_localized_discussion_label(): + summarizer = DailySummarizer() + item = _make_item(1) + item.metadata["discussion_url"] = "https://www.reddit.com/r/python/comments/abc123/test/" + + result = summarizer.generate_webhook_item( + item, + language="zh", + index=1, + total=1, + ) + + assert "[社区讨论](https://www.reddit.com/r/python/comments/abc123/test/)" in result + + +def test_generate_summary_zh_uses_localized_selection_header_and_numeric_date(): + summarizer = DailySummarizer() + item = _make_item(1) + + result = _run_async( + summarizer.generate_summary( + [item], + date="2026-04-25", + total_fetched=10, + language="zh", + ) + ) + + assert "> 从 10 条内容中筛选出 1 条重要资讯。" in result + assert "rss · tester · 4月25日 08:00" in result + assert "From 10 items" not in result + assert "Apr 25, 08:00" not in result + + +def test_generate_empty_summary_zh_uses_localized_analyzed_line(): + summarizer = DailySummarizer() + + result = _run_async( + summarizer.generate_summary( + [], + date="2026-04-25", + total_fetched=10, + language="zh", + ) + ) + + assert "> 已分析 10 条内容,但没有达到重要性阈值的条目。" in result + assert "Analyzed 10 items" not in result diff --git a/tests/test_telegram.py b/tests/test_telegram.py new file mode 100644 index 0000000..d3cf2e0 --- /dev/null +++ b/tests/test_telegram.py @@ -0,0 +1,68 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +from bs4 import BeautifulSoup + +from src.models import TelegramChannelConfig, TelegramConfig +from src.scrapers.telegram import TelegramScraper + + +def _scraper() -> TelegramScraper: + return TelegramScraper(TelegramConfig(), AsyncMock()) + + +def test_parse_message_ignores_reply_preview_text() -> None: + html = """ +
    +
    + +
    + 腾讯混元 Hy3 preview 模型发布并开源 +
    +
    +
    + 腾讯混元 Hy3 正式发布:幻觉率减半,任务解决率升至 90%
    +
    + 腾讯混元今日正式发布 Hy3 模型。
    +
    + Huggingface +
    + + + +
    +
    + """ + msg = BeautifulSoup(html, "html.parser").select_one("div.tgme_widget_message") + + item = _scraper()._parse_message( + msg, + TelegramChannelConfig(channel="zaihuapd"), + datetime(2026, 7, 6, 0, 0, tzinfo=timezone.utc), + ) + + assert item is not None + assert item.title == "腾讯混元 Hy3 正式发布:幻觉率减半,任务解决率升至 90%" + assert item.content is not None + assert "preview 模型发布" not in item.content + assert "正式发布 Hy3 模型" in item.content + assert str(item.url) == "https://huggingface.co/tencent/Hunyuan-Hy3" + + +def test_parse_channel_html_keeps_supported_messages() -> None: + html = """ +
    +
    Hello
    world
    + +
    + """ + cfg = TelegramChannelConfig(channel="channel", fetch_limit=10) + + items = _scraper()._parse_channel_html( + html, + cfg, + datetime(2026, 7, 6, 0, 0, tzinfo=timezone.utc), + ) + + assert len(items) == 1 + assert items[0].title == "Hello world" diff --git a/tests/test_twitter.py b/tests/test_twitter.py new file mode 100644 index 0000000..b85cf07 --- /dev/null +++ b/tests/test_twitter.py @@ -0,0 +1,420 @@ +"""Tests for TwitterScraper.""" + +import asyncio +from datetime import datetime, timedelta, timezone + +import httpx + +from src.models import TwitterConfig +from src.scrapers.twitter import TwitterScraper + + +def _make_config(**kwargs) -> TwitterConfig: + defaults = dict( + enabled=True, + users=["karpathy"], + fetch_limit=3, + actor_id="altimis~scweet", + apify_token_env="APIFY_TOKEN", + ) + defaults.update(kwargs) + return TwitterConfig(**defaults) + + +def _tweet( + tweet_id: str = "123456", + screen_name: str = "karpathy", + text: str = "Hello world", + created_at: str = None, + conversation_id: str = None, + **extra, +) -> dict: + if created_at is None: + now = datetime.now(timezone.utc) + created_at = now.strftime("%a %b %d %H:%M:%S +0000 %Y") + return { + "id_str": tweet_id, + "conversation_id": conversation_id or tweet_id, + "created_at": created_at, + "full_text": text, + "user": {"screen_name": screen_name, "name": screen_name.capitalize()}, + "favorite_count": 10, + "retweet_count": 2, + "reply_count": 1, + **extra, + } + + +def _reply_row( + tweet_id: str = "999", + handle: str = "someone", + text: str = "Great point!", + likes: int = 5, +) -> dict: + now = datetime.now(timezone.utc) + return { + "id": f"tweet-{tweet_id}", + "handle": handle, + "text": text, + "favorite_count": likes, + "reply_count": 0, + "created_at": now.strftime("%a %b %d %H:%M:%S +0000 %Y"), + "user": {"handle": handle, "name": handle}, + } + + +def _run_resp(run_id="run1", dataset_id="ds1"): + return {"data": {"id": run_id, "defaultDatasetId": dataset_id}} + + +def _status_resp(status="SUCCEEDED"): + return {"data": {"status": status}} + + +# --------------------------------------------------------------------------- +# Existing fetch tests +# --------------------------------------------------------------------------- + +def test_disabled_returns_empty(): + transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[])) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run( + TwitterScraper(_make_config(enabled=False), client).fetch( + datetime.now(timezone.utc) + ) + ) + asyncio.run(client.aclose()) + assert result == [] + + +def test_no_users_returns_empty(): + transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[])) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run( + TwitterScraper(_make_config(users=[]), client).fetch( + datetime.now(timezone.utc) + ) + ) + asyncio.run(client.aclose()) + assert result == [] + + +def test_missing_token_returns_empty(monkeypatch): + monkeypatch.delenv("APIFY_TOKEN", raising=False) + transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[])) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run( + TwitterScraper(_make_config(), client).fetch(datetime.now(timezone.utc)) + ) + asyncio.run(client.aclose()) + assert result == [] + + +def test_successful_fetch_returns_items(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + since = datetime.now(timezone.utc) - timedelta(hours=1) + tweets = [_tweet("1"), _tweet("2", text="Another tweet")] + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + return httpx.Response(200, json=_run_resp()) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp()) + if "/datasets/" in request.url.path: + return httpx.Response(200, json=tweets) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since)) + asyncio.run(client.aclose()) + + assert len(result) == 2 + assert result[0].source_type.value == "twitter" + assert result[0].metadata["favorite_count"] == 10 + + +def test_metadata_keys_aligned_for_analyzer(monkeypatch): + """Analyzer reads favorite_count/retweet_count/reply_count — verify they are set.""" + monkeypatch.setenv("APIFY_TOKEN", "test_token") + since = datetime.now(timezone.utc) - timedelta(hours=1) + tweets = [_tweet("42", **{"favorite_count": 99, "retweet_count": 7, "reply_count": 3})] + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + return httpx.Response(200, json=_run_resp()) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp()) + if "/datasets/" in request.url.path: + return httpx.Response(200, json=tweets) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since)) + asyncio.run(client.aclose()) + + assert len(result) == 1 + meta = result[0].metadata + assert meta["favorite_count"] == 99 + assert meta["retweet_count"] == 7 + assert meta["reply_count"] == 3 + assert meta["tweet_id"] == "42" + assert "conversation_id" in meta + + +def test_filters_old_tweets(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + since = datetime.now(timezone.utc) + old = (since - timedelta(hours=2)).strftime("%a %b %d %H:%M:%S +0000 %Y") + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + return httpx.Response(200, json=_run_resp()) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp()) + if "/datasets/" in request.url.path: + return httpx.Response(200, json=[_tweet("1", created_at=old)]) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since)) + asyncio.run(client.aclose()) + assert result == [] + + +def test_run_failure_returns_empty(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + return httpx.Response(200, json=_run_resp()) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp("FAILED")) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run( + TwitterScraper(_make_config(), client).fetch( + datetime.now(timezone.utc) - timedelta(hours=1) + ) + ) + asyncio.run(client.aclose()) + assert result == [] + + +def test_start_run_http_error_returns_empty(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + transport = httpx.MockTransport(lambda r: httpx.Response(500, text="error")) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run( + TwitterScraper(_make_config(), client).fetch( + datetime.now(timezone.utc) - timedelta(hours=1) + ) + ) + asyncio.run(client.aclose()) + assert result == [] + + +def test_no_results_item_skipped(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + since = datetime.now(timezone.utc) - timedelta(hours=1) + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + return httpx.Response(200, json=_run_resp()) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp()) + if "/datasets/" in request.url.path: + return httpx.Response(200, json=[{"noResults": True}, _tweet("99")]) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since)) + asyncio.run(client.aclose()) + + assert len(result) == 1 + assert result[0].id == "twitter:tweet:99" + + +def test_iso_date_fallback(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + since = datetime.now(timezone.utc) - timedelta(hours=1) + iso_tweet = _tweet("77") + iso_tweet["created_at"] = datetime.now(timezone.utc).isoformat() + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + return httpx.Response(200, json=_run_resp()) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp()) + if "/datasets/" in request.url.path: + return httpx.Response(200, json=[iso_tweet]) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since)) + asyncio.run(client.aclose()) + assert len(result) == 1 + + +def test_url_constructed_when_missing(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + since = datetime.now(timezone.utc) - timedelta(hours=1) + tweet = _tweet("55", screen_name="testuser") + tweet.pop("url", None) + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + return httpx.Response(200, json=_run_resp()) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp()) + if "/datasets/" in request.url.path: + return httpx.Response(200, json=[tweet]) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + result = asyncio.run(TwitterScraper(_make_config(), client).fetch(since)) + asyncio.run(client.aclose()) + + assert len(result) == 1 + assert "testuser" in str(result[0].url) + assert "55" in str(result[0].url) + + +# --------------------------------------------------------------------------- +# Reply fetch tests +# --------------------------------------------------------------------------- + +def test_fetch_replies_disabled_by_default(): + """fetch_reply_text defaults to False — verify config default.""" + cfg = TwitterConfig() + assert cfg.fetch_reply_text is False + + +def test_fetch_replies_appends_top_comments(monkeypatch): + """When fetch_reply_text=True, reply lines are appended under Top Comments.""" + monkeypatch.setenv("APIFY_TOKEN", "test_token") + + replies = [ + _reply_row("r1", "alice", "Interesting take!", likes=20), + _reply_row("r2", "bob", "I disagree because...", likes=5), + _reply_row("r3", "carol", "Great point!", likes=15), + _reply_row("r4", "dave", "Low quality reply", likes=0), + ] + + run_counter = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if "/runs" in request.url.path and request.method == "POST": + run_counter["n"] += 1 + ds = f"ds{run_counter['n']}" + return httpx.Response(200, json=_run_resp(f"run{run_counter['n']}", ds)) + if "/actor-runs/" in request.url.path: + return httpx.Response(200, json=_status_resp()) + if "/datasets/" in request.url.path: + return httpx.Response(200, json=replies) + raise AssertionError(f"Unexpected: {request.url}") + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + cfg = _make_config( + fetch_reply_text=True, + max_replies_per_tweet=3, + reply_min_likes=1, + ) + scraper = TwitterScraper(cfg, client) + + from src.models import ContentItem, SourceType + from datetime import datetime, timezone + + item = ContentItem( + id="twitter:tweet:42", + source_type=SourceType.TWITTER, + title="@karpathy: test tweet", + url="https://twitter.com/karpathy/status/42", + content="test tweet body", + author="Andrej Karpathy", + published_at=datetime.now(timezone.utc), + metadata={"tweet_id": "42", "conversation_id": "42"}, + ) + + reply_lines = asyncio.run(scraper.fetch_replies_for_item(item)) + asyncio.run(client.aclose()) + + # min_likes=1 filters out dave (0 likes); max 3 returned sorted by score + assert len(reply_lines) == 3 + # alice (20 likes) should be first + assert "alice" in reply_lines[0] + assert "Interesting take!" in reply_lines[0] + # dave (0 likes) filtered out + assert not any("dave" in l for l in reply_lines) + + +def test_append_discussion_content_adds_marker(): + from src.models import ContentItem, SourceType + + item = ContentItem( + id="twitter:tweet:1", + source_type=SourceType.TWITTER, + title="test", + url="https://twitter.com/x/status/1", + content="original text", + author="x", + published_at=datetime.now(timezone.utc), + metadata={}, + ) + changed = TwitterScraper.append_discussion_content(item, ["[@alice | ❤️ 5 | 💬 1] reply text"]) + assert changed is True + assert "--- Top Comments ---" in item.content + assert "alice" in item.content + + +def test_append_discussion_content_empty_lines_no_change(): + from src.models import ContentItem, SourceType + + item = ContentItem( + id="twitter:tweet:2", + source_type=SourceType.TWITTER, + title="test", + url="https://twitter.com/x/status/2", + content="original", + author="x", + published_at=datetime.now(timezone.utc), + metadata={}, + ) + changed = TwitterScraper.append_discussion_content(item, []) + assert changed is False + assert item.content == "original" + + +def test_fetch_replies_no_conversation_id_returns_empty(monkeypatch): + monkeypatch.setenv("APIFY_TOKEN", "test_token") + transport = httpx.MockTransport(lambda r: httpx.Response(200, json=[])) + client = httpx.AsyncClient(transport=transport) + cfg = _make_config(fetch_reply_text=True) + scraper = TwitterScraper(cfg, client) + + from src.models import ContentItem, SourceType + + item = ContentItem( + id="twitter:tweet:x", + source_type=SourceType.TWITTER, + title="test", + url="https://twitter.com/x/status/x", + content="body", + author="x", + published_at=datetime.now(timezone.utc), + metadata={}, # no conversation_id + ) + result = asyncio.run(scraper.fetch_replies_for_item(item)) + asyncio.run(client.aclose()) + assert result == [] + + + diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..8c15c6f --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,1671 @@ +"""Unit tests for webhook notification service.""" + +import asyncio +import json +import os +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +from pydantic import ValidationError + +from src.models import ContentItem, SourceType, WebhookConfig +from src.services.webhook import ( + WebhookNotifier, + _format_markdown_for_webhook, + _prepare_variables_for_body, + _render, + _truncate, + _isjson, + _extract_headers, + redact_headers, + redact_url, +) +from src.ai.summarizer import DailySummarizer + +_TEST_URL_ENV = "TEST_WEBHOOK_URL" +_TEST_URL = "https://example.com/webhook" + + +# ── Template variable replacement ── + + +class TestRender: + def test_simple_replacement(self): + template = "Hello #{name}, today is #{date}" + variables = {"name": "Horizon", "date": "2026-04-24"} + assert _render(template, variables) == "Hello Horizon, today is 2026-04-24" + + def test_no_matching_vars(self): + template = "Hello #{unknown}" + variables = {"name": "Horizon"} + assert _render(template, variables) == "Hello #{unknown}" + + def test_empty_template(self): + assert _render("", {"date": "2026-04-24"}) == "" + + def test_empty_vars(self): + assert _render("Hello #{name}", {}) == "Hello #{name}" + + def test_numeric_values(self): + template = "#{item_count} items, #{timestamp} seconds" + variables = {"item_count": 15, "timestamp": 1745500000} + assert _render(template, variables) == "15 items, 1745500000 seconds" + + def test_summary_with_multiline_content(self): + template = '{"text": "#{summary}"}' + summary = "## Title\n\nLine 1\nLine 2" + variables = {"summary": summary} + result = _render(template, variables) + assert summary in result + + +class TestRenderDictAndList: + def test_simple_dict(self): + obj = {"title": "Horizon #{date}", "count": "#{item_count} items"} + variables = {"date": "2026-04-24", "item_count": 15} + result = _render(obj, variables) + assert result == {"title": "Horizon 2026-04-24", "count": "15 items"} + + def test_nested_dict(self): + obj = { + "msg_type": "interactive", + "card": { + "schema": "2.0", + "header": {"title": "Horizon #{date}"}, + "body": {"elements": [{"tag": "markdown", "content": "#{summary}"}]}, + }, + } + variables = {"date": "2026-04-24", "summary": "## AI News\nLine 1"} + result = _render(obj, variables) + assert result["card"]["header"]["title"] == "Horizon 2026-04-24" + assert result["card"]["body"]["elements"][0]["content"] == "## AI News\nLine 1" + + def test_list(self): + obj = ["#{date}", "#{result}", "static"] + variables = {"date": "2026-04-24", "result": "success"} + result = _render(obj, variables) + assert result == ["2026-04-24", "success", "static"] + + def test_non_string_values_preserved(self): + obj = {"count": 10, "flag": True, "extra": None, "text": "#{date}"} + variables = {"date": "2026-04-24"} + result = _render(obj, variables) + assert result["count"] == 10 + assert result["flag"] is True + assert result["extra"] is None + assert result["text"] == "2026-04-24" + + def test_no_matching_vars(self): + obj = {"key": "#{unknown}"} + result = _render(obj, {"name": "test"}) + assert result == {"key": "#{unknown}"} + + def test_summary_with_quotes_safely_replaced(self): + """Verify that quotes in summary don't break the JSON structure.""" + obj = {"content": "#{summary}"} + summary = 'AI called "GPT-5" is great' + result = _render(obj, {"summary": summary}) + # When serialized to JSON, the quotes should be properly escaped + serialized = json.dumps(result) + parsed_back = json.loads(serialized) + assert parsed_back["content"] == summary + + +class TestTruncate: + def test_short_value_not_truncated(self): + value = "hello" + result = _truncate(value, limit=100, split="---") + assert result == value + + def test_truncate_by_segments(self): + # "aaa---bbb---ccc" → segments: "aaa"(3), "bbb"(3+3=6), "ccc"(3+3=6) + # limit=10 → keep "aaa"(3) + "bbb"(6) = 9 ≤ 10, drop "ccc" + value = "aaa---bbb---ccc" + result = _truncate(value, limit=10, split="---") + assert result == "aaa---bbb" + + def test_single_segment_exceeds_limit_still_kept(self): + # First segment alone exceeds limit, but we always keep it + value = "abcdefghij---xyz" + result = _truncate(value, limit=5, split="---") + assert result == "abcdefghij" + assert "xyz" not in result + + def test_no_split_delimiter_in_value(self): + # Value doesn't contain the split delimiter — returned as-is + value = "abcdefghij" + result = _truncate(value, limit=5, split="---") + # Without delimiter, entire value is one segment, always kept + assert result == value + + def test_empty_value(self): + result = _truncate("", limit=10, split="---") + assert result == "" + + def test_exact_limit_with_join(self): + # "aaa---bbb" → seg1=3, seg2=3+3(join)=6, total=9 + # limit=9 → exact fit, keep both + value = "aaa---bbb" + result = _truncate(value, limit=9, split="---") + assert result == value + + def test_one_char_over_limit(self): + # "aaa---bbb" → total=9 chars, limit=8 → drop "bbb" + value = "aaa---bbb" + result = _truncate(value, limit=8, split="---") + assert result == "aaa" + + +class TestRenderParameterized: + def test_plain_key_without_params(self): + """#{summary} without params works as before.""" + template = "#{summary}" + result = _render(template, {"summary": "hello world"}) + assert result == "hello world" + + def test_key_with_limit_and_split(self): + """#{summary?limit=10&split=---} truncates by character count.""" + # "aaa---bbb---ccc" → keep "aaa---bbb" (9 chars ≤ 10), drop "ccc" + summary = "aaa---bbb---ccc" + template = "#{summary?limit=10&split=---}" + result = _render(template, {"summary": summary}) + assert result == "aaa---bbb" + + def test_key_with_limit_no_truncation_needed(self): + """When value fits within limit, no truncation occurs.""" + summary = "short text" + template = "#{summary?limit=100&split=---}" + result = _render(template, {"summary": summary}) + assert result == summary + + def test_missing_variable_with_params(self): + """#{unknown?limit=5&split=---} with missing key leaves placeholder.""" + template = "#{unknown?limit=5&split=---}" + result = _render(template, {"date": "2026-04-24"}) + assert result == "#{unknown?limit=5&split=---}" + + def test_param_in_dict_body(self): + """#{summary?limit=10&split=---} works inside dict request_body.""" + obj = {"content": "#{summary?limit=10&split=---}", "title": "#{date}"} + summary = "aaa---bbb---ccc" + result = _render(obj, {"summary": summary, "date": "2026-04-24"}) + assert result["title"] == "2026-04-24" + assert result["content"] == "aaa---bbb" + + def test_mix_of_plain_and_parameterized(self): + """Plain #{date} and parameterized #{summary?...} in same template.""" + template = "#{date}: #{summary?limit=20&split=---}" + summary = "aaa---bbb---ccc" + result = _render(template, {"date": "2026-04-24", "summary": summary}) + assert result == "2026-04-24: aaa---bbb---ccc" + + +class TestWebhookMarkdownFormatting: + def test_details_references_are_flattened_for_webhook(self): + summary = """## Item + + +
    参考链接 + +
    +""" + + result = _format_markdown_for_webhook(summary) + + assert "
    " not in result + assert "" not in result + assert '' not in result + assert "**参考链接**" in result + assert "- [Example A](https://example.com/a)" in result + assert "- [Example B](https://example.com/b)" in result + + def test_details_references_with_unsafe_href_remain_plain_text(self): + summary = """## Item + +
    References + +
    +""" + + result = _format_markdown_for_webhook(summary) + + assert "javascript:alert(1)" not in result + assert "[click](javascript:alert(1))" not in result + assert "- click \\[me\\]\\(https://evil.example\\)" in result + + def test_details_references_with_malformed_http_href_remain_plain_text(self): + summary = """## Item + +
    References + +
    +""" + + result = _format_markdown_for_webhook(summary) + + assert "javascript:alert(1)" not in result + assert "[click](https://safe.example/)" not in result + assert "- click" in result + + def test_details_references_allow_balanced_parentheses_in_href(self): + summary = """## Item + +
    References + +
    +""" + + result = _format_markdown_for_webhook(summary) + + assert ( + "- [Colossus](https://en.wikipedia.org/wiki/Colossus_(supercomputer))" + in result + ) + + def test_prepare_variables_changes_summary_for_any_post_body(self): + summary = "
    References
    • Plain item
    " + variables = {"summary": summary, "date": "2026-04-24"} + body = {"text": "#{summary}"} + + result = _prepare_variables_for_body(body, variables) + + assert result is not variables + assert result["summary"] == "**References**\n\n- Plain item" + assert variables["summary"] == summary + + def test_prepare_variables_keeps_summary_unchanged_without_body(self): + summary = "
    References
    • Plain item
    " + variables = {"summary": summary} + + result = _prepare_variables_for_body(None, variables) + + assert result is variables + assert result["summary"] == summary + + +class TestWebhookPreview: + def test_build_preview_uses_same_summary_formatting_as_send_path(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body={ + "msg_type": "interactive", + "card": { + "body": { + "elements": [{"tag": "markdown", "content": "#{summary}"}] + }, + }, + }, + ) + notifier = WebhookNotifier(config) + + preview = notifier.build_preview( + { + "summary": '
    References
    ', + } + ) + + assert preview["url"] == _TEST_URL + assert "**References**" in preview["body"] + assert "
    " not in preview["body"] + del os.environ[_TEST_URL_ENV] + + def test_build_preview_uses_request_body_override(self): + os.environ[_TEST_URL_ENV] = "https://example.com/webhook?token=secret" + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body={"content": "configured"}, + headers="Authorization: Bearer secret\nX-Trace: ok", + ) + notifier = WebhookNotifier(config) + + preview = notifier.build_preview( + { + "_request_body_override": {"content": "override"}, + } + ) + + parsed = json.loads(preview["body"]) + assert parsed["content"] == "override" + assert preview["url"] == _TEST_URL + assert preview["headers"]["Authorization"] == "" + assert preview["headers"]["X-Trace"] == "ok" + assert preview["headers"]["Content-Type"] == "application/json" + del os.environ[_TEST_URL_ENV] + + +# ── JSON prefix detection ── + + +class TestIsJson: + def test_object(self): + assert _isjson('{"key": "value"}') is True + + def test_array(self): + assert _isjson("[1, 2, 3]") is True + + def test_whitespace_before_brace(self): + assert _isjson(' {"key": 1}') is True + + def test_plain_string(self): + assert _isjson("hello world") is False + + def test_form_data(self): + assert _isjson("key=value&foo=bar") is False + + def test_empty(self): + assert _isjson("") is False + + +# ── Header parsing ── + + +class TestExtractHeaders: + def test_single_header(self): + assert _extract_headers("Content-Type: application/json") == { + "Content-Type": "application/json" + } + + def test_multiple_headers(self): + result = _extract_headers("Authorization: Bearer abc\nX-Custom: value") + assert result == {"Authorization": "Bearer abc", "X-Custom": "value"} + + def test_empty_string(self): + assert _extract_headers("") == {} + + def test_none(self): + assert _extract_headers(None) == {} + + def test_blank_lines(self): + result = _extract_headers("Key: val\n\nAnother: val2") + assert result == {"Key": "val", "Another": "val2"} + + def test_invalid_line(self): + result = _extract_headers("NoColonHere\nValid: yes") + assert result == {"Valid": "yes"} + + +class TestWebhookRedaction: + def test_redact_url_removes_query_and_fragment(self): + assert ( + redact_url("https://example.com/hook?token=secret#frag") + == "https://example.com/hook" + ) + + def test_redact_headers_masks_sensitive_values(self): + assert redact_headers({"Authorization": "Bearer secret", "X-Trace": "ok"}) == { + "Authorization": "", + "X-Trace": "ok", + } + + +# ── WebhookNotifier ── + + +def _run_async(coro): + """Helper to run async coroutine in tests.""" + return asyncio.run(coro) + + +class TestWebhookNotifier: + def test_disabled_skips(self): + config = WebhookConfig(enabled=False, url_env=_TEST_URL_ENV) + os.environ[_TEST_URL_ENV] = _TEST_URL + notifier = WebhookNotifier(config) + assert notifier.config.enabled is False + del os.environ[_TEST_URL_ENV] + + def test_disabled_webhook_skips_notification(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig(enabled=False, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + with patch("httpx.AsyncClient") as mock_client: + _run_async(notifier.notify({"date": "2026-04-24"})) + mock_client.assert_not_called() + del os.environ[_TEST_URL_ENV] + + def test_empty_url_env_skips_notification(self): + # url_env not set in environment + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + assert notifier.url is None + with patch("httpx.AsyncClient") as mock_client: + _run_async(notifier.notify({"date": "2026-04-24"})) + mock_client.assert_not_called() + + def test_get_request_when_no_body(self): + os.environ[_TEST_URL_ENV] = "https://example.com/webhook?date=#{date}" + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + _run_async(notifier.notify({"date": "2026-04-24", "result": "success"})) + mock_client.get.assert_called_once() + call_url = mock_client.get.call_args[0][0] + assert "2026-04-24" in call_url + del os.environ[_TEST_URL_ENV] + + def test_post_request_with_json_body(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body='{"msg_type": "post", "content": "Horizon #{date} #{item_count} items"}', + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + _run_async(notifier.notify({"date": "2026-04-24", "item_count": 15})) + mock_client.post.assert_called_once() + + call_kwargs = mock_client.post.call_args[1] + assert call_kwargs["headers"]["Content-Type"] == "application/json" + + body_bytes = call_kwargs["content"] + body_str = body_bytes.decode("utf-8") + parsed = json.loads(body_str) + assert parsed["content"] == "Horizon 2026-04-24 15 items" + del os.environ[_TEST_URL_ENV] + + def test_post_request_with_json_str_body_containing_summary(self): + """String JSON body with #{summary} that contains special characters. + + Note: when request_body is a string, #{summary} is replaced via + simple string substitution. If #{summary} contains unescaped quotes + or newlines, the resulting JSON string may become invalid. This test + documents that known limitation — use dict request_body for safe + handling of #{summary}. + """ + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body='{"msg_type": "post", "content": "#{summary}"}', + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + # summary without special chars — should parse fine + summary = "Horizon daily report: 10 items" + _run_async(notifier.notify({"summary": summary})) + mock_client.post.assert_called_once() + + call_kwargs = mock_client.post.call_args[1] + body_str = call_kwargs["content"].decode("utf-8") + parsed = json.loads(body_str) + assert parsed["content"] == summary + del os.environ[_TEST_URL_ENV] + + def test_post_request_with_json_str_body_summary_with_quotes_breaks_json(self): + """String JSON body where #{summary} has quotes — JSON becomes invalid. + + This demonstrates the limitation: with string request_body, #{summary} + containing quotes will break the JSON structure. The Content-Type falls + back to application/x-www-form-urlencoded because json.loads fails. + Use dict request_body instead for safe handling of #{summary}. + """ + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body='{"msg_type": "post", "content": "#{summary}"}', + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + summary = 'AI called "GPT-5" is great' + _run_async(notifier.notify({"summary": summary})) + mock_client.post.assert_called_once() + + call_kwargs = mock_client.post.call_args[1] + # json.loads fails on the rendered string, so content-type + # falls back to form-urlencoded + assert ( + call_kwargs["headers"]["Content-Type"] + == "application/x-www-form-urlencoded" + ) + del os.environ[_TEST_URL_ENV] + + def test_post_request_with_form_body(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body="date=#{date}&result=#{result}", + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + _run_async(notifier.notify({"date": "2026-04-24", "result": "success"})) + mock_client.post.assert_called_once() + + call_kwargs = mock_client.post.call_args[1] + assert ( + call_kwargs["headers"]["Content-Type"] + == "application/x-www-form-urlencoded" + ) + del os.environ[_TEST_URL_ENV] + + def test_custom_headers(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body='{"key": "value"}', + headers="X-Auth: token123\nX-Secret: abc", + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + _run_async(notifier.notify({"date": "2026-04-24"})) + call_kwargs = mock_client.post.call_args[1] + assert call_kwargs["headers"]["X-Auth"] == "token123" + assert call_kwargs["headers"]["X-Secret"] == "abc" + del os.environ[_TEST_URL_ENV] + + def test_post_request_with_dict_body(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body={ + "msg_type": "interactive", + "card": { + "schema": "2.0", + "header": {"title": "Horizon #{date}"}, + "body": { + "elements": [{"tag": "markdown", "content": "#{summary}"}] + }, + }, + }, + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + _run_async( + notifier.notify({"date": "2026-04-24", "summary": "## News\nLine 1"}) + ) + mock_client.post.assert_called_once() + + call_kwargs = mock_client.post.call_args[1] + assert call_kwargs["headers"]["Content-Type"] == "application/json" + + body_str = call_kwargs["content"].decode("utf-8") + parsed = json.loads(body_str) + assert parsed["card"]["header"]["title"] == "Horizon 2026-04-24" + assert parsed["card"]["body"]["elements"][0]["content"] == "## News\nLine 1" + del os.environ[_TEST_URL_ENV] + + def test_post_request_with_dict_body_and_special_chars(self): + """Summary containing quotes should be properly serialized.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + request_body={"content": "#{summary}"}, + ) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + summary = 'AI called "GPT-5" is great' + _run_async(notifier.notify({"summary": summary})) + mock_client.post.assert_called_once() + + body_str = mock_client.post.call_args[1]["content"].decode("utf-8") + parsed = json.loads(body_str) + assert parsed["content"] == summary + del os.environ[_TEST_URL_ENV] + + def test_request_body_override_sends_post_without_configured_body(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + _run_async( + notifier.notify({"_request_body_override": {"content": "override"}}) + ) + + mock_client.post.assert_called_once() + body = json.loads(mock_client.post.call_args[1]["content"].decode("utf-8")) + assert body == {"content": "override"} + del os.environ[_TEST_URL_ENV] + + def test_http_error_logged(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + ) + notifier = WebhookNotifier(config) + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock( + side_effect=httpx.ConnectError("Connection refused") + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + # Should not raise — error is logged and printed + _run_async(notifier.notify({"date": "2026-04-24"})) + del os.environ[_TEST_URL_ENV] + + +# ── Config model validation ── + + +class TestWebhookConfigModel: + def test_default_values(self): + config = WebhookConfig() + assert config.enabled is False + assert config.url_env is None + assert config.request_body is None + assert config.headers is None + assert config.delivery == "summary" + assert config.platform == "generic" + assert config.layout == "markdown" + assert config.fallback_layout == "markdown" + + def test_full_config(self): + config = WebhookConfig( + enabled=True, + url_env="HORIZON_WEBHOOK_URL", + request_body='{"msg_type":"post"}', + headers="Authorization: Bearer xxx", + delivery="summary_and_items", + overview_position="last", + platform="feishu", + layout="collapsible", + fallback_layout="markdown", + languages=["zh"], + ) + assert config.enabled is True + assert config.url_env == "HORIZON_WEBHOOK_URL" + assert config.delivery == "summary_and_items" + assert config.overview_position == "last" + assert config.platform == "feishu" + assert config.layout == "collapsible" + assert config.fallback_layout == "markdown" + assert config.languages == ["zh"] + + +# ── Helper to build a ContentItem for testing ── + + +def _make_item(title="Test Item", url="https://example.com/test", score=8.0): + """Create a minimal ContentItem for webhook tests.""" + return ContentItem( + id="github:test:1", + source_type=SourceType.GITHUB, + title=title, + url=url, + content="Some content", + author="testuser", + published_at=datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc), + fetched_at=datetime(2026, 4, 24, 12, 0, 0, tzinfo=timezone.utc), + ai_score=score, + ai_summary="AI summary", + ai_tags=["test"], + ) + + +# ── send_daily_summary ── + + +class TestSendDailySummary: + def test_summary_delivery_calls_notify_once(self): + """delivery='summary' sends a single notify call with message_kind='summary'.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary", + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [_make_item()] + summary = "# Horizon Daily\nTest summary" + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary=summary, + important_items=items, + all_items_count=10, + date="2026-04-24", + lang="en", + summarizer=summarizer, + ) + ) + mock_notify.assert_called_once() + vars = mock_notify.call_args[0][0] + assert vars["message_kind"] == "summary" + assert vars["message_title"] == "Horizon 2026-04-24 Daily" + assert vars["summary"] == summary + assert vars["important_items"] == 1 + assert vars["all_items"] == 10 + assert vars["result"] == "success" + assert vars["language"] == "en" + del os.environ[_TEST_URL_ENV] + + def test_summary_delivery_zh_lang(self): + """Chinese lang uses '日报' in message_title.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary", + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [_make_item()] + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary="## 测试摘要", + important_items=items, + all_items_count=5, + date="2026-04-24", + lang="zh", + summarizer=summarizer, + ) + ) + vars = mock_notify.call_args[0][0] + assert vars["message_title"] == "Horizon 2026-04-24 日报" + assert vars["language"] == "zh" + del os.environ[_TEST_URL_ENV] + + def test_summary_and_items_delivery_calls_notify_multiple_times(self): + """delivery='summary_and_items' sends overview + N item notifications.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary_and_items", + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [ + _make_item(title="Item A"), + _make_item(title="Item B", url="https://example.com/b"), + ] + summary = "# Full summary" + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary=summary, + important_items=items, + all_items_count=20, + date="2026-04-24", + lang="en", + summarizer=summarizer, + ) + ) + # 1 overview + 2 items = 3 calls + assert mock_notify.call_count == 3 + + # First call: overview + overview_vars = mock_notify.call_args_list[0][0][0] + assert overview_vars["message_kind"] == "overview" + assert overview_vars["message_title"] == "Horizon 2026-04-24 Overview" + + # Second call: first item + item1_vars = mock_notify.call_args_list[1][0][0] + assert item1_vars["message_kind"] == "item" + assert item1_vars["item_index"] == 1 + assert item1_vars["item_count"] == 2 + assert item1_vars["item_url"] == "https://example.com/test" + + # Third call: second item + item2_vars = mock_notify.call_args_list[2][0][0] + assert item2_vars["message_kind"] == "item" + assert item2_vars["item_index"] == 2 + assert item2_vars["item_url"] == "https://example.com/b" + del os.environ[_TEST_URL_ENV] + + def test_summary_and_items_overview_last_sends_reversed_items_then_overview(self): + """overview_position='last' keeps overview as newest chat message.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary_and_items", + overview_position="last", + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [ + _make_item(title="Item A"), + _make_item(title="Item B", url="https://example.com/b"), + ] + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary="# Full summary", + important_items=items, + all_items_count=20, + date="2026-04-24", + lang="en", + summarizer=summarizer, + ) + ) + + assert mock_notify.call_count == 3 + + first_vars = mock_notify.call_args_list[0][0][0] + second_vars = mock_notify.call_args_list[1][0][0] + third_vars = mock_notify.call_args_list[2][0][0] + + assert first_vars["message_kind"] == "item" + assert first_vars["item_index"] == 2 + assert first_vars["item_url"] == "https://example.com/b" + assert second_vars["message_kind"] == "item" + assert second_vars["item_index"] == 1 + assert second_vars["item_url"] == "https://example.com/test" + assert third_vars["message_kind"] == "overview" + assert third_vars["message_title"] == "Horizon 2026-04-24 Overview" + del os.environ[_TEST_URL_ENV] + + def test_feishu_collapsible_layout_builds_single_card_message(self): + """Feishu collapsible layout sends one card with collapsed item panels.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary_and_items", + platform="feishu", + layout="collapsible", + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [ + _make_item(title="Item A"), + _make_item(title="Item B", url="https://example.com/b"), + ] + + messages = notifier.build_daily_summary_messages( + summary="# Full summary", + important_items=items, + all_items_count=20, + date="2026-04-24", + lang="en", + summarizer=summarizer, + ) + + assert len(messages) == 1 + assert messages[0]["message_kind"] == "collapsible" + + body = messages[0]["_request_body_override"] + assert body["msg_type"] == "interactive" + assert body["card"]["schema"] == "2.0" + + elements = body["card"]["body"]["elements"] + assert "Expand the panels below" in elements[0]["content"] + assert "Item A" not in elements[0]["content"] + panels = [ + element for element in elements if element["tag"] == "collapsible_panel" + ] + assert len(panels) == 2 + assert panels[0]["expanded"] is False + assert panels[0]["header"]["title"]["content"].startswith("1. Item A") + assert "Item 1/2" in panels[0]["elements"][0]["content"] + assert panels[1]["header"]["title"]["content"].startswith("2. Item B") + del os.environ[_TEST_URL_ENV] + + def test_language_filter_skips_non_matching_lang(self): + """webhook.languages=['zh'] skips 'en' language.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary", + languages=["zh"], + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [_make_item()] + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary="English summary", + important_items=items, + all_items_count=10, + date="2026-04-24", + lang="en", + summarizer=summarizer, + ) + ) + mock_notify.assert_not_called() + del os.environ[_TEST_URL_ENV] + + def test_language_filter_passes_matching_lang(self): + """webhook.languages=['zh'] allows 'zh' language.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary", + languages=["zh"], + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [_make_item()] + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary="中文摘要", + important_items=items, + all_items_count=10, + date="2026-04-24", + lang="zh", + summarizer=summarizer, + ) + ) + mock_notify.assert_called_once() + del os.environ[_TEST_URL_ENV] + + def test_no_language_filter_sends_all(self): + """webhook.languages=None sends for all languages.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary", + languages=None, + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [_make_item()] + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary="English summary", + important_items=items, + all_items_count=10, + date="2026-04-24", + lang="en", + summarizer=summarizer, + ) + ) + mock_notify.assert_called_once() + del os.environ[_TEST_URL_ENV] + + def test_timestamp_is_current_utc(self): + """timestamp variable reflects the current UTC time.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary", + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [_make_item()] + + before = int(datetime.now(timezone.utc).timestamp()) + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary="test", + important_items=items, + all_items_count=5, + date="2026-04-24", + lang="en", + summarizer=summarizer, + ) + ) + after = int(datetime.now(timezone.utc).timestamp()) + vars = mock_notify.call_args[0][0] + ts = int(vars["timestamp"]) + assert before <= ts <= after + del os.environ[_TEST_URL_ENV] + + def test_summary_and_items_zh_overview_title(self): + """summary_and_items with zh lang uses '总览' in overview title.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + delivery="summary_and_items", + ) + notifier = WebhookNotifier(config) + summarizer = DailySummarizer() + items = [_make_item()] + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_daily_summary( + summary="中文摘要", + important_items=items, + all_items_count=10, + date="2026-04-24", + lang="zh", + summarizer=summarizer, + ) + ) + overview_vars = mock_notify.call_args_list[0][0][0] + assert overview_vars["message_title"] == "Horizon 2026-04-24 总览" + del os.environ[_TEST_URL_ENV] + +# ── send_failure_notification ── + + +class TestSendFailureNotification: + def test_failure_calls_notify_with_failure_vars(self): + """send_failure_notification sends notify with correct failure vars.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + ) + notifier = WebhookNotifier(config) + + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_failure( + date="2026-04-24", + error_message="something went wrong", + ) + ) + mock_notify.assert_called_once() + vars = mock_notify.call_args[0][0] + assert vars["date"] == "2026-04-24" + assert vars["result"] == "failed" + assert vars["language"] == "" + assert vars["important_items"] == 0 + assert vars["all_items"] == 0 + assert vars["message_kind"] == "failure" + assert vars["message_title"] == "Horizon generation failed" + assert "something went wrong" in vars["summary"] + del os.environ[_TEST_URL_ENV] + + def test_failure_timestamp_is_current_utc(self): + """Failure notification timestamp reflects current UTC time.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig( + enabled=True, + url_env=_TEST_URL_ENV, + ) + notifier = WebhookNotifier(config) + + before = int(datetime.now(timezone.utc).timestamp()) + with patch.object(notifier, "notify", new_callable=AsyncMock) as mock_notify: + _run_async( + notifier.send_failure( + date="2026-04-24", + error_message="error", + ) + ) + after = int(datetime.now(timezone.utc).timestamp()) + vars = mock_notify.call_args[0][0] + ts = int(vars["timestamp"]) + assert before <= ts <= after + del os.environ[_TEST_URL_ENV] + + +# ── URL validation ── + + +class TestURLValidation: + def test_valid_https_url_passes(self): + os.environ[_TEST_URL_ENV] = "https://example.com/webhook" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + assert notifier.url == "https://example.com/webhook" + del os.environ[_TEST_URL_ENV] + + def test_valid_http_url_passes(self): + os.environ[_TEST_URL_ENV] = "http://localhost:8080/hook" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + assert notifier.url == "http://localhost:8080/hook" + del os.environ[_TEST_URL_ENV] + + def test_no_hostname_raises_value_error(self): + """URLs without a hostname raise ValueError.""" + os.environ[_TEST_URL_ENV] = "http://" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + with pytest.raises(ValueError, match="no hostname"): + WebhookNotifier(config) + del os.environ[_TEST_URL_ENV] + + def test_wrong_scheme_raises_value_error(self): + """URLs with non-http/https scheme raise ValueError.""" + for bad_url in ["ftp://example.com", "not-a-url", "://"]: + os.environ[_TEST_URL_ENV] = bad_url + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + try: + with pytest.raises(ValueError, match="http or https"): + WebhookNotifier(config) + finally: + del os.environ[_TEST_URL_ENV] + + def test_invalid_port_raises_value_error(self): + """httpx.URL catches structurally invalid ports like 'abc'.""" + os.environ[_TEST_URL_ENV] = "http://example.com:abc" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + with pytest.raises(ValueError, match="structurally invalid"): + WebhookNotifier(config) + del os.environ[_TEST_URL_ENV] + + def test_empty_env_var_value_raises_value_error(self): + """Env var exists but is empty string → ValueError.""" + os.environ[_TEST_URL_ENV] = "" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + with pytest.raises(ValueError, match="empty"): + WebhookNotifier(config) + del os.environ[_TEST_URL_ENV] + + def test_env_var_not_set_sets_url_none(self): + """url_env configured but env var doesn't exist → url=None + console warning.""" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + os.environ.pop(_TEST_URL_ENV, None) + notifier = WebhookNotifier(config) + assert notifier.url is None + + def test_url_env_null_sets_url_none(self): + """url_env=None in config → url=None + console warning.""" + config = WebhookConfig(enabled=True, url_env=None) + notifier = WebhookNotifier(config) + assert notifier.url is None + + def test_whitespace_url_stripped_and_validated(self): + """URL with surrounding whitespace is stripped before validation.""" + os.environ[_TEST_URL_ENV] = " https://example.com/webhook " + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + assert notifier.url == "https://example.com/webhook" + del os.environ[_TEST_URL_ENV] + + def test_shell_escape_artifacts_stripped(self): + """Shell escape artifacts like \\? and \\= are auto-stripped from URL.""" + os.environ[_TEST_URL_ENV] = "https://oapi.dingtalk.com/robot/send\\?access_token\\=abc123" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + assert notifier.url == "https://oapi.dingtalk.com/robot/send?access_token=abc123" + del os.environ[_TEST_URL_ENV] + + +# ── HTTP status code handling ── + + +class TestHTTPStatusHandling: + def _make_notifier(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + return notifier + + def _cleanup(self): + del os.environ[_TEST_URL_ENV] + + def test_2xx_success_prints_response(self): + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"code":0,"msg":"ok"}' + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "status=200" in printed + assert '"code":0' in printed + # Success response should be green, not yellow + assert "[green]" in printed + self._cleanup() + + def test_2xx_feishu_error_code_prints_yellow_warning(self): + """Feishu returns HTTP 200 with code=19001 in body — should be yellow warning.""" + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"code":19001,"msg":"param invalid: incoming webhook access token invalid"}' + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "19001" in printed + assert "Feishu/Lark" in printed + assert "[yellow]" in printed + self._cleanup() + + def test_2xx_dingtalk_error_code_prints_yellow_warning(self): + """DingTalk returns HTTP 200 with errcode=400 in body — should be yellow warning.""" + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"errcode":400,"errmsg":"invalid token"}' + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "errcode=400" in printed + assert "DingTalk" in printed + assert "[yellow]" in printed + self._cleanup() + + def test_2xx_slack_ok_false_prints_yellow_warning(self): + """Slack returns HTTP 200 with ok=false — should be yellow warning.""" + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"ok":false,"error":"invalid_token"}' + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "Slack/Discord" in printed + assert "[yellow]" in printed + self._cleanup() + + def test_2xx_non_json_body_prints_green(self): + """Non-JSON 2xx response body prints as green (no error code check possible).""" + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "OK" + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "status=200" in printed + assert "[green]" in printed + self._cleanup() + + def test_3xx_redirect_prints_warning(self): + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 301 + mock_response.text = "" + mock_response.headers = {"location": "https://new-url.com"} + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "redirect" in printed.lower() + self._cleanup() + + def test_4xx_client_error_prints_warning(self): + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Bad request" + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "client error" in printed.lower() + self._cleanup() + + def test_5xx_server_error_prints_warning(self): + notifier = self._make_notifier() + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal server error" + + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "server error" in printed.lower() + self._cleanup() + + +# ── Exception classification ── + + +class TestExceptionClassification: + def _make_notifier(self): + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + return notifier + + def _cleanup(self): + del os.environ[_TEST_URL_ENV] + + def test_connect_error_prints_warning(self): + notifier = self._make_notifier() + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "connection failed" in printed.lower() + self._cleanup() + + def test_timeout_exception_prints_warning(self): + notifier = self._make_notifier() + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("Timed out")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "timed out" in printed.lower() + self._cleanup() + + def test_invalid_url_exception_prints_warning(self): + notifier = self._make_notifier() + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=httpx.InvalidURL("Bad URL")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "invalid" in printed.lower() + self._cleanup() + + def test_generic_exception_prints_type_name(self): + notifier = self._make_notifier() + mock_console = MagicMock() + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=RuntimeError("Something unexpected")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + notifier.console = mock_console + _run_async(notifier.notify({"date": "2026-04-24"})) + + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "RuntimeError" in printed + assert "unexpectedly" in printed.lower() + self._cleanup() + + +# ── Config field validation ── + + +class TestWebhookConfigFieldValidation: + def test_invalid_delivery_raises_validation_error(self): + with pytest.raises(ValidationError, match="delivery"): + WebhookConfig(enabled=True, delivery="invalid_mode") + + def test_invalid_platform_raises_validation_error(self): + with pytest.raises(ValidationError, match="platform"): + WebhookConfig(enabled=True, platform="unknown_platform") + + def test_invalid_layout_raises_validation_error(self): + with pytest.raises(ValidationError, match="layout"): + WebhookConfig(enabled=True, layout="html") + + def test_invalid_fallback_layout_raises_validation_error(self): + with pytest.raises(ValidationError, match="fallback_layout"): + WebhookConfig(enabled=True, fallback_layout="html") + + def test_invalid_overview_position_raises_validation_error(self): + with pytest.raises(ValidationError, match="overview_position"): + WebhookConfig(enabled=True, overview_position="middle") + + def test_all_valid_values_pass(self): + config = WebhookConfig( + enabled=True, + delivery="summary_and_items", + platform="feishu", + layout="collapsible", + fallback_layout="markdown", + overview_position="last", + ) + assert config.delivery == "summary_and_items" + assert config.platform == "feishu" + assert config.layout == "collapsible" + assert config.fallback_layout == "markdown" + assert config.overview_position == "last" + + def test_each_valid_platform(self): + for p in ["generic", "feishu", "lark", "dingtalk", "slack", "discord"]: + config = WebhookConfig(enabled=True, platform=p) + assert config.platform == p + + +# ── Skip console output ── + + +class TestSkipConsoleOutput: + def test_disabled_webhook_prints_warning(self): + """When webhook is disabled, notify() prints a yellow warning.""" + os.environ[_TEST_URL_ENV] = _TEST_URL + config = WebhookConfig(enabled=False, url_env=_TEST_URL_ENV) + notifier = WebhookNotifier(config) + mock_console = MagicMock() + notifier.console = mock_console + + _run_async(notifier.notify({"date": "2026-04-24"})) + + mock_console.print.assert_called_once() + printed = str(mock_console.print.call_args) + assert "disabled" in printed.lower() + del os.environ[_TEST_URL_ENV] + + def test_empty_url_prints_warning(self): + """When URL is empty (env var not set), notify() prints a yellow warning.""" + config = WebhookConfig(enabled=True, url_env=_TEST_URL_ENV) + os.environ.pop(_TEST_URL_ENV, None) + notifier = WebhookNotifier(config) + mock_console = MagicMock() + notifier.console = mock_console + + _run_async(notifier.notify({"date": "2026-04-24"})) + + # notify() prints warning when URL is empty + assert mock_console.print.call_count >= 1 + printed = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "not set" in printed.lower() or "empty" in printed.lower() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..869dfc2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3616 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiohttp-client-cache" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "attrs" }, + { name = "itsdangerous" }, + { name = "url-normalize" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/a1/d3ba3a88c9ee596bc84954fdcb0e060fcbc027309a58809b088c0ad1a749/aiohttp_client_cache-0.11.1.tar.gz", hash = "sha256:32e63ad210240f8224f3e12772fe53ac102cf24c7cf18ddb86acbb9fdf9e4b6f", size = 62482, upload-time = "2024-08-01T22:26:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/3f/376765c8e8b5d40ed25923e8959ac1e7465376f9326901e8192381b1c747/aiohttp_client_cache-0.11.1-py3-none-any.whl", hash = "sha256:06ea196e35219a6f1ecc2f96639106eeea5fc1ec9808c805aa3a2e5cbfa62df6", size = 32703, upload-time = "2024-08-01T22:26:52.065Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/3a/22ff5415bf4d296c1e92b07fd746ad42c96781f13295a074d58e77747848/aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7", size = 21691, upload-time = "2024-02-20T06:12:53.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c4/c93eb22025a2de6b83263dfe3d7df2e19138e345bca6f18dba7394120930/aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6", size = 15564, upload-time = "2024-02-20T06:12:50.657Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/e5/02cd2919ec327b24234abb73082e6ab84c451182cc3cc60681af700f4c63/anthropic-0.83.0.tar.gz", hash = "sha256:a8732c68b41869266c3034541a31a29d8be0f8cd0a714f9edce3128b351eceb4", size = 534058, upload-time = "2026-02-19T19:26:38.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/75/b9d58e4e2a4b1fc3e75ffbab978f999baf8b7c4ba9f96e60edb918ba386b/anthropic-0.83.0-py3-none-any.whl", hash = "sha256:f069ef508c73b8f9152e8850830d92bd5ef185645dbacf234bb213344a274810", size = 456991, upload-time = "2026-02-19T19:26:40.114Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +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/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[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 = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" }, + { url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" }, + { url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" }, + { url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/293a9a0a7caf17e6e657668bebb92dfe730305999fe8c0e2703b8888789c/brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6", size = 343085, upload-time = "2025-11-21T18:17:48.887Z" }, + { url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586, upload-time = "2025-11-21T18:17:50.531Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ec/52488a0563f1663e2ccc75834b470650f4b8bcdea3132aef3bf67219c661/brotlicffi-1.2.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fa102a60e50ddbd08de86a63431a722ea216d9bc903b000bf544149cc9b823dc", size = 402002, upload-time = "2025-11-21T18:17:51.76Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/d4aea4835fd97da1401d798d9b8ba77227974de565faea402f520b37b10f/brotlicffi-1.2.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d3c4332fc808a94e8c1035950a10d04b681b03ab585ce897ae2a360d479037c", size = 406447, upload-time = "2025-11-21T18:17:53.614Z" }, + { url = "https://files.pythonhosted.org/packages/62/4e/5554ecb2615ff035ef8678d4e419549a0f7a28b3f096b272174d656749fb/brotlicffi-1.2.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb4eb5830026b79a93bf503ad32b2c5257315e9ffc49e76b2715cffd07c8e3db", size = 402521, upload-time = "2025-11-21T18:17:54.875Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/b07f8f125ac52bbee5dc00ef0d526f820f67321bf4184f915f17f50a4657/brotlicffi-1.2.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3832c66e00d6d82087f20a972b2fc03e21cd99ef22705225a6f8f418a9158ecc", size = 374730, upload-time = "2025-11-21T18:17:56.334Z" }, +] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "courlan" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "tld" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/16/2a771612ee0b3acaa95ac21cc7e8a3319e815d6360f8ffc5987d1ce28499/courlan-1.4.0.tar.gz", hash = "sha256:fbbac7b7fcde2195ea08e707609503c81cf39c891e8d26cdb1fed4585782d63d", size = 208997, upload-time = "2026-06-01T17:30:17.306Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/38/ce65091ff20a16e06d17418c4353af5f56d3190821b1a06983c79ae79274/courlan-1.4.0-py3-none-any.whl", hash = "sha256:ad1dbdefd912ca7238d4607dc855df5df097f56bac175dd662c84eed3802f49e", size = 34193, upload-time = "2026-06-01T17:30:14.984Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, +] + +[[package]] +name = "dateparser" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/f4/561c49bca97af561d34eed27e3e831135eb5cb88e754c1150be41820f5c6/dateparser-1.4.1.tar.gz", hash = "sha256:f265df13c0380e2e07543ba74b67c0681aaa1096981ffcd35227e1aa0cb81c7c", size = 314734, upload-time = "2026-06-15T08:45:47.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/7c/2e5dcf53909deddd0bf38cbe277ad9806be038276b1c6c436561b4d9b2e2/dateparser-1.4.1-py3-none-any.whl", hash = "sha256:f25d4e051a84be27a35bd297e3e1dc59ff78373701b89be352ba80372d22d0d0", size = 300503, upload-time = "2026-06-15T08:45:45.951Z" }, +] + +[[package]] +name = "ddgs" +version = "9.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "fake-useragent" }, + { name = "httpx", extra = ["brotli", "http2", "socks"] }, + { name = "lxml" }, + { name = "primp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/76/8dc0323d1577037abad7a679f8af150ebb73a94995d3012de71a8898e6e6/ddgs-9.10.0.tar.gz", hash = "sha256:d9381ff75bdf1ad6691d3d1dc2be12be190d1d32ecd24f1002c492143c52c34f", size = 31491, upload-time = "2025-12-17T23:30:15.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/0e/d4b7d6a8df5074cf67bc14adead39955b0bf847c947ff6cad0bb527887f4/ddgs-9.10.0-py3-none-any.whl", hash = "sha256:81233d79309836eb03e7df2a0d2697adc83c47c342713132c0ba618f1f2c6eee", size = 40311, upload-time = "2025-12-17T23:30:13.606Z" }, +] + +[[package]] +name = "deepdiff" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/20/63dd34163ed07393968128dc8c7ab948c96e47c4ce76976ea533de64909d/deepdiff-9.0.0.tar.gz", hash = "sha256:4872005306237b5b50829803feff58a1dfd20b2b357a55de22e7ded65b2008a7", size = 151952, upload-time = "2026-03-30T05:52:23.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/c4/da7089cd7aa4ab554f56e18a7fb08dcfed8fd2ae91fa528f5b1be207a148/deepdiff-9.0.0-py3-none-any.whl", hash = "sha256:b1ae0dd86290d86a03de5fbee728fde43095c1472ae4974bdab23ab4656305bd", size = 170540, upload-time = "2026-03-30T05:52:22.008Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "fake-useragent" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898, upload-time = "2025-04-14T15:32:19.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, +] + +[[package]] +name = "feedparser" +version = "6.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sgmllib3k" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" }, +] + +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.64.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/14/344b450d4387845fc5c8b7f168ffbe734b831b729ece3333fc0fe8556f04/google_genai-1.64.0.tar.gz", hash = "sha256:8db94ab031f745d08c45c69674d1892f7447c74ed21542abe599f7888e28b924", size = 496434, upload-time = "2026-02-19T02:06:13.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/56/765eca90c781fedbe2a7e7dc873ef6045048e28ba5f2d4a5bcb13e13062b/google_genai-1.64.0-py3-none-any.whl", hash = "sha256:78a4d2deeb33b15ad78eaa419f6f431755e7f0e03771254f8000d70f717e940b", size = 728836, upload-time = "2026-02-19T02:06:11.655Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, + { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, + { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", size = 234989, upload-time = "2026-05-20T13:08:27.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[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 = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "horizon" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "anthropic" }, + { name = "beautifulsoup4" }, + { name = "ddgs" }, + { name = "feedparser" }, + { name = "google-genai" }, + { name = "httpx" }, + { name = "markdown" }, + { name = "mcp" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "tenacity" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] +openbb = [ + { name = "openbb" }, + { name = "openbb-benzinga" }, +] +trafilatura = [ + { name = "trafilatura" }, +] +twitter = [ + { name = "playwright" }, + { name = "playwright-stealth" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.39.0" }, + { name = "beautifulsoup4", specifier = ">=4.12.0" }, + { name = "ddgs", specifier = ">=7.0.0" }, + { name = "feedparser", specifier = ">=6.0.11" }, + { name = "google-genai", specifier = ">=0.3.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "markdown", specifier = ">=3.10.2" }, + { name = "mcp", specifier = ">=1.0.0" }, + { name = "openai", specifier = ">=1.54.0" }, + { name = "openbb", marker = "extra == 'openbb'", specifier = ">=4.4.0" }, + { name = "openbb-benzinga", marker = "extra == 'openbb'", specifier = ">=1.6.0" }, + { name = "playwright", marker = "extra == 'twitter'", specifier = ">=1.40.0" }, + { name = "playwright-stealth", marker = "extra == 'twitter'", specifier = ">=1.0.0" }, + { name = "pydantic", specifier = ">=2.9.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "python-dateutil", specifier = ">=2.9.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "rich", specifier = ">=13.9.0" }, + { name = "tenacity", specifier = ">=9.0.0" }, + { name = "trafilatura", marker = "extra == 'trafilatura'", specifier = ">=2.1.0" }, +] +provides-extras = ["dev", "openbb", "twitter", "trafilatura"] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + +[[package]] +name = "htmldate" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "dateparser" }, + { name = "lxml" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/1f/e7cf83e23d7b68105de8b874a8b36ba23b450d6f71388583e4ca3ce475ca/htmldate-1.10.0.tar.gz", hash = "sha256:a38df10772ab5d7dbb11896e3f6a852a8491fb1b0965465bc174e23fc2baae58", size = 44455, upload-time = "2026-06-01T17:43:53.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/17/d3356233c826c641f940983d9479eab27faec59d49f4070bc58e80fcc021/htmldate-1.10.0-py3-none-any.whl", hash = "sha256:9211dae35ab94147c8ed9e5fc2c9287a5cf31d2394cb7857e7f5dd814eb2aad6", size = 31561, upload-time = "2026-06-01T17:43:51.797Z" }, +] + +[[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.optional-dependencies] +brotli = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, +] +http2 = [ + { name = "h2" }, +] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[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 = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "justext" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml", extra = ["html-clean"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/f3/45890c1b314f0d04e19c1c83d534e611513150939a7cf039664d9ab1e649/justext-3.0.2.tar.gz", hash = "sha256:13496a450c44c4cd5b5a75a5efcd9996066d2a189794ea99a49949685a0beb05", size = 828521, upload-time = "2025-02-25T20:21:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/ac/52f4e86d1924a7fc05af3aeb34488570eccc39b4af90530dd6acecdf16b5/justext-3.0.2-py2.py3-none-any.whl", hash = "sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7", size = 837940, upload-time = "2025-02-25T20:21:44.179Z" }, +] + +[[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.optional-dependencies] +html-clean = [ + { name = "lxml-html-clean" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/63/195dfdde380a84df309e3bccf4384b034b745dba43426886f7ae623b4fba/lxml_html_clean-0.4.5.tar.gz", hash = "sha256:e2a4c7d5beedd17cd7b484d848a0571e54baa239a4f9df5546e3acba7f990560", size = 24142, upload-time = "2026-05-20T12:17:53.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/bd/6e2b76a6c5dee10397db9c929f0c5066766ec1036046f0335b7ca7ca08b8/lxml_html_clean-0.4.5-py3-none-any.whl", hash = "sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746", size = 14573, upload-time = "2026-05-20T12:17:52.215Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "multitasking" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/ac2cc9307fb15cc28ed6d4a9266b216c83ee7fe64299f0264047982bce88/multitasking-0.0.13.tar.gz", hash = "sha256:d896b5df877c9ca5eeddbf0e5994124694d6cb535aba698fb23344c7025155a1", size = 20585, upload-time = "2026-04-23T12:14:15.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/1c/24dbf69b247f287401c904a396233a43c89fd4fb9b7cd2e50e430e9cd57c/multitasking-0.0.13-py3-none-any.whl", hash = "sha256:ec9243af140c67bfe52dc98d7173c294512735a88e8425c458b250db99dc2b48", size = 16380, upload-time = "2026-04-23T12:14:13.776Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "openai" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, +] + +[[package]] +name = "openbb" +version = "4.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-benzinga" }, + { name = "openbb-bls" }, + { name = "openbb-cftc" }, + { name = "openbb-commodity" }, + { name = "openbb-congress-gov" }, + { name = "openbb-core" }, + { name = "openbb-crypto" }, + { name = "openbb-currency" }, + { name = "openbb-derivatives" }, + { name = "openbb-econdb" }, + { name = "openbb-economy" }, + { name = "openbb-equity" }, + { name = "openbb-etf" }, + { name = "openbb-federal-reserve" }, + { name = "openbb-fixedincome" }, + { name = "openbb-fmp" }, + { name = "openbb-fred" }, + { name = "openbb-government-us" }, + { name = "openbb-imf" }, + { name = "openbb-index" }, + { name = "openbb-intrinio" }, + { name = "openbb-news" }, + { name = "openbb-oecd" }, + { name = "openbb-platform-api" }, + { name = "openbb-regulators" }, + { name = "openbb-sec" }, + { name = "openbb-tiingo" }, + { name = "openbb-tradingeconomics" }, + { name = "openbb-us-eia" }, + { name = "openbb-yfinance" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/c2/f7d33931d73c59b5c8138504bf722795e3c874b99661de59f2870cbdadac/openbb-4.7.1.tar.gz", hash = "sha256:89c363f4aa8814940d36dfdeb9b8e61449e7ef80edd99d1cbbd9eb98c4e67b2b", size = 5702, upload-time = "2026-03-09T16:16:50.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/62/791dee5d4b0b654f4541329c6572a9530d8b98ccf642526a249c10b8867a/openbb-4.7.1-py3-none-any.whl", hash = "sha256:d2f7bf1fecc3c01dd19e4f627b1ce272ba6eb5f7c3b2fd02beed9b05fc7b746c", size = 5977, upload-time = "2026-03-09T16:16:51.577Z" }, +] + +[[package]] +name = "openbb-benzinga" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/01/71ebccf8b59d115d2693fd9842827a21ba56309c11d546c32a126be7b024/openbb_benzinga-1.6.0.tar.gz", hash = "sha256:ea3804697edddee432de7deae0651e639a55ec3db27fb7c03afbccbeaca57f1c", size = 8037, upload-time = "2026-03-05T01:42:05.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ef/ded2771b0a873ebfdd3a6107589bd7e6b802d571536932d1719c64396c03/openbb_benzinga-1.6.0-py3-none-any.whl", hash = "sha256:85b14bcf35b18fc94b61ff9ca54e6318598cd7915f3a6ce87e06fb0fcd7ac35b", size = 13914, upload-time = "2026-03-05T01:42:05.924Z" }, +] + +[[package]] +name = "openbb-bls" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/7a/0508b61acd397e8c0d66a204da9d3242e67c034af1c3d48fd8c45a9aea26/openbb_bls-1.3.0.tar.gz", hash = "sha256:06cdb42ad940547291d8e9b6364b1d44bcfa5ff6147e3975899fb300d8759621", size = 9829091, upload-time = "2026-03-05T01:42:32.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/78/22c0aca017442c229e179f46d8536b6da5417aabb5f674f9efed1e1be05d/openbb_bls-1.3.0-py3-none-any.whl", hash = "sha256:f244bc97df093fdc4c34994c9d6d37cda61d278e010b6e9f4567d38141f0a780", size = 9818269, upload-time = "2026-03-05T01:42:42.944Z" }, +] + +[[package]] +name = "openbb-cftc" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/c1/691c594a2543ca832ecc5bc177f06be560399264d0f30ee31165eb7fb0e1/openbb_cftc-1.3.0.tar.gz", hash = "sha256:51c82ff2ac56e91d699f748fcd73edf6831d5b54b2a89e23facffeecb0e3391c", size = 10563, upload-time = "2026-03-05T01:43:01.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/bb/77e0471b3855136b7b6122785088d4f08ec9efbc0b6c93242a398cbea930/openbb_cftc-1.3.0-py3-none-any.whl", hash = "sha256:d573abaf2ee867d3d62041e17c900fb5ce0025b881b74995e0b88c13ace4e7b6", size = 12677, upload-time = "2026-03-05T01:43:02.129Z" }, +] + +[[package]] +name = "openbb-commodity" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/76/a966d7060532d3b93d5a17df88c43710e2d59fee3f3d7d7abc50304fbac8/openbb_commodity-1.5.1.tar.gz", hash = "sha256:53f0e542bece0785617fd00a4bfa887fbadb127eaba44a271c3b21eff12c0c78", size = 3748, upload-time = "2026-03-05T01:35:36.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/84/9d5ab9fb33e4e4cce7572ea912ee6f7f2cea62d31f72d5afe32ba65a9fce/openbb_commodity-1.5.1-py3-none-any.whl", hash = "sha256:131c040e2b5eedac70a8f7094452811ec3ed35d51bee7c7e726abf1d452261f5", size = 5398, upload-time = "2026-03-05T01:35:37.147Z" }, +] + +[[package]] +name = "openbb-congress-gov" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/24/f6959d326a9fa02719d953a71b96e95a57cfb7877b390043497ddbf20020/openbb_congress_gov-1.2.2.tar.gz", hash = "sha256:c5e36e2f48f6fefc6ddaf053f4dcbc2c5756eb65fe27d09b240d94c36fa5bfbd", size = 16136, upload-time = "2026-03-09T23:51:01.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/18/abac8654b1d69025223184710c5056e29ce8175db9c2e9cb1529a4d20b3b/openbb_congress_gov-1.2.2-py3-none-any.whl", hash = "sha256:603da98f172ade642b6021c25886a617ae62f8a0481a372e96e53e5d14a652ec", size = 21048, upload-time = "2026-03-09T23:51:02.342Z" }, +] + +[[package]] +name = "openbb-core" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "fastapi" }, + { name = "html5lib" }, + { name = "importlib-metadata" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "ruff" }, + { name = "uuid7" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/d3/4c99668437ea5fea84c3b2ac609cbaaf68827258f723da10583195127933/openbb_core-1.6.9.tar.gz", hash = "sha256:3cfaeae08104f395c80ce2949ed93142b835075b5cb7e7e8a1729d81cac65d01", size = 244609, upload-time = "2026-05-08T01:18:59.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/5e/91641f227ef4d7fa5fed0d13c92cf191089bf256b9456f4bdca0db1eb3bc/openbb_core-1.6.9-py3-none-any.whl", hash = "sha256:09172982421960bf66ff0c8944921eaea0e1a79b8e4a6aa71550ec03b5944981", size = 380273, upload-time = "2026-05-08T01:19:01.197Z" }, +] + +[[package]] +name = "openbb-crypto" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/34/de330cfae848fef5de257f907d4afa8d37cc53b3d4786c29dfe80aae88b8/openbb_crypto-1.6.1.tar.gz", hash = "sha256:a3acfe776cdd685de4d7a707c6ba1b2ff1344cf756c6aa071510689032875172", size = 1954, upload-time = "2026-03-05T01:39:13.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/18/dc1fb4879299df4d9aaa1c1b859c3edc0758783d92def4a9c798aac07610/openbb_crypto-1.6.1-py3-none-any.whl", hash = "sha256:ecf31c023594e44605163438e61ffcaf10226a7cf5cd2cda2b521126d86b7e67", size = 3975, upload-time = "2026-03-05T01:39:14.175Z" }, +] + +[[package]] +name = "openbb-currency" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/c9/edeaa9161f6346cbf209948540da92493afdce52a9ea1bede4c9e44c2e1b/openbb_currency-1.6.1.tar.gz", hash = "sha256:50f872c258e7855647450204cd9098cd4ac41cb556fb03a072aaf99336644878", size = 2755, upload-time = "2026-03-05T01:39:22.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/5e/eedec42b18ad2ac1c77f3de1b1eec77b18cbd42f9105b993ddf82d54e51b/openbb_currency-1.6.1-py3-none-any.whl", hash = "sha256:e31e04139833358c088f913888d3d47aafc1977cbc2faec845bc7532d0222e94", size = 4877, upload-time = "2026-03-05T01:39:23.58Z" }, +] + +[[package]] +name = "openbb-derivatives" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/5c/ca3c9dd9ed1f2c85a7eaf2cba99b2ca1b6b7abe2c3e56b99f0bea498fbe7/openbb_derivatives-1.6.1.tar.gz", hash = "sha256:d00ccbb29be3bf200101b9942f0be54fc1306a603419066cdbaf0ed860127c26", size = 7480, upload-time = "2026-03-05T01:39:31.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/2f/47d04a75e76aecffd40967c2244b2ff86e326b84aed32dd5f2dc84d65a23/openbb_derivatives-1.6.1-py3-none-any.whl", hash = "sha256:bde02ce81ba98a45fa0a7a8e548327c2b64fa6d903de2ac79b2b29874b42d7ab", size = 10167, upload-time = "2026-03-05T01:39:32.383Z" }, +] + +[[package]] +name = "openbb-econdb" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp-client-cache" }, + { name = "aiosqlite" }, + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/d1/0d1ada5d6a81eb4ebca64a0bfda90191234d62fa8d48cd0798e4fb61d22f/openbb_econdb-1.5.0.tar.gz", hash = "sha256:f9753934bec7b1f59d87126e7bbc73d6f5b20b6f26df235ca77c3e0f85d48cef", size = 86016, upload-time = "2026-03-05T01:43:49.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/1f/86f5a1d53fcbccfb70ae0e8c2f58f65983a289ba44301af9f592f474bdd2/openbb_econdb-1.5.0-py3-none-any.whl", hash = "sha256:d613305ac0c3ef1205eec7d4086d49719ea3a75d12d4e5dd401d5f73c4c58e64", size = 99836, upload-time = "2026-03-05T01:43:55.494Z" }, +] + +[[package]] +name = "openbb-economy" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/c4/8a2d4b42c20888407148bbc22a877103ca878435765893f06de1b6785523/openbb_economy-1.6.1.tar.gz", hash = "sha256:ff69ac293742d4b9d2812f9d58faa5f4e8b3d7cc1e681c598434ff3371310c26", size = 12813, upload-time = "2026-03-05T01:40:01.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/c5/73e05d11fdebddda0e6786d15976ed41e90ac92ec526a0a5171a92194b65/openbb_economy-1.6.1-py3-none-any.whl", hash = "sha256:d9da6acb0ca56fa8316fd04d54c88bb4edd7df3fad68128e80c429c8499b7669", size = 15913, upload-time = "2026-03-05T01:40:02.598Z" }, +] + +[[package]] +name = "openbb-equity" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b7/c3b1aa9acd07876db89222c4c71b18c7911bd2552625690b8501b2763c24/openbb_equity-1.6.1.tar.gz", hash = "sha256:0114e71b5bd3e423f06a71514e6eff3272f5c5d8cce0a7368fe3ac25f2887f54", size = 8409, upload-time = "2026-03-05T01:40:10.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/eb/90c59ffb3bd0582e0743f30b887dfbb0f91b535f3a280f7e17137a08f564/openbb_equity-1.6.1-py3-none-any.whl", hash = "sha256:f3c94c0415d4bc6fdb254f6040d137be76bcf73223edc00703162b0d0c57ac33", size = 16477, upload-time = "2026-03-05T01:40:11.704Z" }, +] + +[[package]] +name = "openbb-etf" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/68/09cb2fad6dd30714d6bcd70d30bf842fffc4e4abc7915943dc197d287f4b/openbb_etf-1.6.1.tar.gz", hash = "sha256:1dbf9f750212e67d140a3a4ee8f65784a9172303e96645cd7c08f980a128eb0f", size = 3330, upload-time = "2026-03-05T01:40:19.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/cf/6a6b6af20fae1bb996e2afd0202307aed0e893442a87817d68c637e706c8/openbb_etf-1.6.1-py3-none-any.whl", hash = "sha256:b62bfd1ce781c60c5e7f045ad56adb831db077535a15d2afd03901decb31abc2", size = 5312, upload-time = "2026-03-05T01:40:20.193Z" }, +] + +[[package]] +name = "openbb-federal-reserve" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "openbb-core" }, + { name = "openbb-economy" }, + { name = "openbb-fixedincome" }, + { name = "openbb-platform-api" }, + { name = "openpyxl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/1c/27fda960c2fc270ef3c0760ee9a72e156498cde8ab8ef986c649dea5df27/openbb_federal_reserve-1.6.1.tar.gz", hash = "sha256:50cff43e31535f7de176589f94aa0f2f2fe835fc755f00794a4e3a1c52cf423c", size = 83992, upload-time = "2026-03-24T23:20:32.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/d4/b0c9b36e47230d43230f1ccbc2a61875157a6caa9efce6bc8ad2b251350b/openbb_federal_reserve-1.6.1-py3-none-any.whl", hash = "sha256:73f8bbac7f9fb729a9c872d9fc9c3ae3234bf6ece237e1235d00b1853cea599c", size = 97699, upload-time = "2026-03-24T23:20:33.547Z" }, +] + +[[package]] +name = "openbb-fixedincome" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d0/0d67451bb7a9caf2c2124f67b4ddfc3857f6cdd69a4fd93a23dd96de5f4e/openbb_fixedincome-1.6.1.tar.gz", hash = "sha256:34068e569a104a764542aa4a8f925eadbd71170da85153f3a71989afa199e658", size = 7979, upload-time = "2026-03-05T01:40:28.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/24/ccd355a56c51652845e5325e880ddbedcda287dc838f3b6bea4dc560e526/openbb_fixedincome-1.6.1-py3-none-any.whl", hash = "sha256:2293c933415855342c659aadd1da5919bbed8fe2aa06bb9c14199df090ff449f", size = 12534, upload-time = "2026-03-05T01:40:29.717Z" }, +] + +[[package]] +name = "openbb-fmp" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/72/632b6b83acfa48f8d0ec2a0291f2f3edb966b7165b02c7c42e1fdd22cd94/openbb_fmp-1.6.0.tar.gz", hash = "sha256:6ac2adb0f7d69465324c778198bc1af3703dd4be9f425ae98babf6e681ae352e", size = 60630, upload-time = "2026-03-05T01:44:41.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/41/7b24ef17be52daf3bc5aa85ed582f63f226e540f3ed59ea49b59dbd0e077/openbb_fmp-1.6.0-py3-none-any.whl", hash = "sha256:833a70a4d7e9cb4a83e9ea25f9fcdd943e68666d131f9917ba85b6f84af86e8c", size = 125522, upload-time = "2026-03-05T01:44:42.687Z" }, +] + +[[package]] +name = "openbb-fred" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/3f/b031f0a8a126fe5cf9a25146b974096314d0b639cf2d4ee45ba0f2313b6c/openbb_fred-1.6.0.tar.gz", hash = "sha256:ae286a6ee84e07366855a102a96a8afa3edf81633615cfd7c0e71e5c2b10b14f", size = 64536, upload-time = "2026-03-05T01:44:50.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/72/f3515f491faa7fefedf1c71e70abaf63d368750a863b251f1a15b608125f/openbb_fred-1.6.0-py3-none-any.whl", hash = "sha256:9a9e6f356e0a05f350e11879de5bfa39f7b92157ea93aad9e066a4d72257ec23", size = 107831, upload-time = "2026-03-05T01:44:51.662Z" }, +] + +[[package]] +name = "openbb-government-us" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, + { name = "random-user-agent" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/d5/e04912855cb37446635ce9682b87ad1f82a2e2cbc9853ca21af87394e6a2/openbb_government_us-1.6.0.tar.gz", hash = "sha256:983aeb7eda58e64e1ab269549c09ccc60e8168f4024c35df9902f8c5f651d180", size = 35844, upload-time = "2026-03-05T01:44:59.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/2b/17ec9aafa8607ad56da2ffcc5174867736071ae220707f86c0edb0e9c91f/openbb_government_us-1.6.0-py3-none-any.whl", hash = "sha256:8a8f8a472ab416e271f6f55fd43e391948b38024d1f021196132f5c967ed148c", size = 43417, upload-time = "2026-03-05T01:45:00.595Z" }, +] + +[[package]] +name = "openbb-imf" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "defusedxml" }, + { name = "openbb-core" }, + { name = "openbb-economy" }, + { name = "openbb-platform-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/1e/337a5d9799abc9253f7518f95c0453fb2de50aed59f31180c0168a1082c1/openbb_imf-2.1.2.tar.gz", hash = "sha256:0180ce1b16ba4313d9ffa39e1ecf7d45ed739129574e2241965f6b02e44ad4e7", size = 3057604, upload-time = "2026-04-06T04:22:14.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/2f/757b27e97c53b64e8faf8fef477414e505f7a03747a0f8cac2837487ff3e/openbb_imf-2.1.2-py3-none-any.whl", hash = "sha256:4401c629195a8ca1d28f16c1c1dcf489ef320b846aa3ac712545d03502c2ab23", size = 3071480, upload-time = "2026-04-06T04:22:16.893Z" }, +] + +[[package]] +name = "openbb-index" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/cc/9dca91b2653fde7363fb0dbcbcd011c84006b808785ccb6a334cbc48bcbf/openbb_index-1.6.1.tar.gz", hash = "sha256:e5f6f0a72b230bd324064982cc475060d7762079008dee54f925ca6c29bb28e4", size = 2163, upload-time = "2026-03-05T01:40:37.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/6c/5dc34e935b57dabc144c28cc126cca815cf964e861c376a8adb9452f8569/openbb_index-1.6.1-py3-none-any.whl", hash = "sha256:6ffc696d57caea15a2dd4132e7dfd0c1187311cc3ea3383570ce4f0e249a646f", size = 4172, upload-time = "2026-03-05T01:40:37.835Z" }, +] + +[[package]] +name = "openbb-intrinio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, + { name = "requests-cache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/0f400db31ea61328677018dc46487db8dd22d6f76150cbb981f400aca7b7/openbb_intrinio-1.6.0.tar.gz", hash = "sha256:05554edce55eb0b0230e88698db3302d64238b4e3b85f36d3f7015c3acaddea3", size = 56559, upload-time = "2026-03-05T01:45:09.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/78/5e9bd71565f049323248501ccefbbf7eb96c48231be14b4b8e0e7e996099/openbb_intrinio-1.6.0-py3-none-any.whl", hash = "sha256:38b66722986d2d88504641f98d828506edcf3220534d114ea64d1d96b20e592d", size = 98572, upload-time = "2026-03-05T01:45:10.549Z" }, +] + +[[package]] +name = "openbb-news" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/4d/ea2e60413877166f787eeed580d015d0bf17c3a784f83401633eca6da676/openbb_news-1.6.1.tar.gz", hash = "sha256:20c702d18fd5b78f117063e553c258242d49fc0414f2a22554ac1a2905e2f420", size = 1709, upload-time = "2026-03-05T01:41:01.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e1/25d005f5d152ca955ff62f0c0949336f6f26a277f069d613b7421b3ed296/openbb_news-1.6.1-py3-none-any.whl", hash = "sha256:ffcb5216e98c87d8839d7454fb0fdda84f46de449bb7b2f39a5273fd17be6cd4", size = 2771, upload-time = "2026-03-05T01:41:02.068Z" }, +] + +[[package]] +name = "openbb-oecd" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/00/8eb3d2a1728527ed93c2ba1a8a6cf9cd10f09cb76d537297c1b0c26d1e39/openbb_oecd-1.6.0.tar.gz", hash = "sha256:d3e6cb09895ed4db2511e5d4c5872b9395b24b1852b1bdb4bc44a45d083531d5", size = 14870, upload-time = "2026-03-05T01:45:29.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/e8/b2ef937be02bdb1d28616019e7c10a21b258849f85349e32fd8ad042e142/openbb_oecd-1.6.0-py3-none-any.whl", hash = "sha256:554b3791e0eb300509e9c14230aba53b434d5ffe1d0466a8cd78384b0150c45f", size = 30168, upload-time = "2026-03-05T01:45:31.091Z" }, +] + +[[package]] +name = "openbb-platform-api" +version = "1.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deepdiff" }, + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/6b/e4f2c4cae8d55860090dfd57584d2698acd7a3629c1987ce10f27340fec7/openbb_platform_api-1.3.5.tar.gz", hash = "sha256:40816bb6ffe18481d7e023a5c36304761e5334d18098c72af7bd6d44f4235932", size = 44445, upload-time = "2026-03-18T22:38:49.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/9f/20fda903668b2d282f97356b195486682b530a7acf495614c80c6310e934/openbb_platform_api-1.3.5-py3-none-any.whl", hash = "sha256:dfa0175006aede9e09906b81832105075747d836b135e945e73af51060731066", size = 43734, upload-time = "2026-03-18T22:38:50.991Z" }, +] + +[[package]] +name = "openbb-regulators" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/44/f409698fbcaa7299c7f0a2ac7163b8734fdd589440943f4a8f1ec35dc827/openbb_regulators-1.6.1.tar.gz", hash = "sha256:f24952ba49bc7299b63d6a69ca94ec92bdb10556595688454d04a729f26a9bca", size = 3026, upload-time = "2026-03-05T01:41:36.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/66/43079f34e53ed5f67a786790601485ae862656635e70403f1486ed4be31c/openbb_regulators-1.6.1-py3-none-any.whl", hash = "sha256:098d5c8a81fa859f00103ed2f26c0de0480f927c513503454a5ab25cb52e493c", size = 5273, upload-time = "2026-03-05T01:41:37.191Z" }, +] + +[[package]] +name = "openbb-sec" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp-client-cache" }, + { name = "aiosqlite" }, + { name = "beautifulsoup4" }, + { name = "defusedxml" }, + { name = "lxml" }, + { name = "openbb-core" }, + { name = "xmltodict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/7b/12794b7dc6cdd3f31484eb3ce82d21c3bfa11ea4aa31dd62c02d9db68c78/openbb_sec-1.6.0.tar.gz", hash = "sha256:1ec3e6f9d3e61fd6377b8824e5272fcedd91a5fb7a73a582ce13799aa571c024", size = 172601, upload-time = "2026-03-05T01:45:40.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/52/c055d73003fa96c7653bf1e21093a9d9f559474f193b7e728bdc7e71124c/openbb_sec-1.6.0-py3-none-any.whl", hash = "sha256:c1cd758e9f1ff7141e703d7357c6bfd19b21bace552cfad1b5c36abc1f148315", size = 190339, upload-time = "2026-03-05T01:45:41.274Z" }, +] + +[[package]] +name = "openbb-tiingo" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ed/7623e969afc39012697fc916b4fab94340f202c7c8ecf50656f72b427b2f/openbb_tiingo-1.6.0.tar.gz", hash = "sha256:1c184b7c538a4809799d8ce1fcf099ea7d0c4e8cdb99c3e7457638cdda4b22b6", size = 6190, upload-time = "2026-03-05T01:46:10.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/55/cfee1850456ba5c212d150de81f73413d2057bcea89f7c6cbc21e29f66dd/openbb_tiingo-1.6.0-py3-none-any.whl", hash = "sha256:6417ce0545e52c6d2477d9289ca83b8b00e07dedc332142d05d7a3fc7e8919ff", size = 13675, upload-time = "2026-03-05T01:46:11.306Z" }, +] + +[[package]] +name = "openbb-tradingeconomics" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/a9/73b3b5be0d3cf5102e0db409eef44fd128867ec5ea824a84dfaf08cf570f/openbb_tradingeconomics-1.6.0.tar.gz", hash = "sha256:a661ea0ad8cbacdf4fb26fda83b013aab087416c95f277ffa1fc1e841a5e98d2", size = 6130, upload-time = "2026-03-05T01:46:39.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/5a/a972475b683f1c1a344dc28bc7c7f5a9685931c76aceaf46290d9aa19b5a/openbb_tradingeconomics-1.6.0-py3-none-any.whl", hash = "sha256:338a7ec9fecb031ec493941f91fac12bd851c8fe1c1d4720c8d124cd353f8c3c", size = 8214, upload-time = "2026-03-05T01:46:40.547Z" }, +] + +[[package]] +name = "openbb-us-eia" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "openbb-core" }, + { name = "openpyxl" }, + { name = "xlrd" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/98/c18262d9f6c897e7eda604647869a5bc062ffbc9b2d2731764a53805240b/openbb_us_eia-1.3.0.tar.gz", hash = "sha256:337c257387669ec25a6e5871f5300a6f82ae88f585ab85041d2cac121a5b6216", size = 26390, upload-time = "2026-03-05T01:44:05.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/d2/71a8de2471b8a898925d0d6b2a680c4b2b665820fb2c188b88ec0e5992f8/openbb_us_eia-1.3.0-py3-none-any.whl", hash = "sha256:ffd3667064d43d9307bf6977fdf00a641ddd3aabebca7c2a416ae6e7ab1a4365", size = 28393, upload-time = "2026-03-05T01:44:05.888Z" }, +] + +[[package]] +name = "openbb-yfinance" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openbb-core" }, + { name = "yfinance" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/37/eb9abe8a04b6b5b2d8b6e6bccd96e3e9fc9582d82339e9b895e940ebf70d/openbb_yfinance-1.6.2.tar.gz", hash = "sha256:353690e9d2e4c69e0c63347f5b131c8b124208f5be04fdb04cdd3dc1ddfc1fad", size = 39114, upload-time = "2026-04-18T00:26:48.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/34/b68ed02b55387ce6f451218cb4ff9512d2124498735213e52f21985fde60/openbb_yfinance-1.6.2-py3-none-any.whl", hash = "sha256:437573b2386a5e0ab6343b8d709ea01fcb4b7a7d989dad8ba7fe76edb66c4575", size = 66147, upload-time = "2026-04-18T00:26:49.357Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "peewee" +version = "4.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/04/8b1f0388389c0595ccf69fec8723983e598edb0303f6227a08bc5d8cd013/peewee-4.0.5.tar.gz", hash = "sha256:b43a21493f19f205a016cb4a9e29c7eca3500576d29447a89732022d193450ba", size = 721062, upload-time = "2026-04-23T21:17:29.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/d4/2cf83b5fb7fae8939ed596009465c6de0b8fafa70abd486ed9d192e457f7/peewee-4.0.5-py3-none-any.whl", hash = "sha256:05bf687660f04f925bcb7d744e5af826ca526322f2fad894bfa4f1d0f3bcaf50", size = 144479, upload-time = "2026-04-23T21:17:28.061Z" }, +] + +[[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 = "playwright-stealth" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/db/6ade5d539c7d151b9defc78fafa8b65aa52352617d0e7699b47008bd801f/playwright_stealth-2.0.3.tar.gz", hash = "sha256:1d8e488fbdd8f190f1269ea8cf5d57d14df3a9f1af1001c41ee3588b2aac3133", size = 25751, upload-time = "2026-04-04T02:50:33.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/10/607c409712c02a26c4cb794820514cb7fdaaeac15fb05bed917fb8a354b3/playwright_stealth-2.0.3-py3-none-any.whl", hash = "sha256:1887ade423ab7ff8ae16d363a30a38de0b5817e1e4a29d47b74bf3a0e3dbfcb4", size = 34385, upload-time = "2026-04-04T02:50:35.246Z" }, +] + +[[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 = "primp" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/60/ea0822d275847ed266d694662cef1863c37d3c1752f4286c4baae5297d3f/primp-1.0.0.tar.gz", hash = "sha256:09fc1ff6009220247d723792794e514782e1ab7e9ba5e2547272a07afed5ca86", size = 973426, upload-time = "2026-02-13T15:32:49.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/ae/443244fb49e2f421dafadd689361777d48b07f0ea7d18b34e72a38a3ef44/primp-1.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6af2343ac655d409ec70c3eeb7c2283de509b663aeb6b3e34e39e1331c82daf6", size = 3893122, upload-time = "2026-02-13T15:33:07.596Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/aa765143ce632bcf5e3cfa8bd41e2032f8d12695754564b5059821b2b41a/primp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:25f21400ff236b0e1db5d4db7db66965f63b64898103384e916ecef575ab3395", size = 3655128, upload-time = "2026-02-13T15:32:41.147Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d7/5e9e320441a7c0ffef24ce55fd2922aacd003e6713633d1d0732fe964ff6/primp-1.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd09660db079903031be91e04af2dcf42457bd739e6f328c7b2364e38061876", size = 3792951, upload-time = "2026-02-13T15:32:56.186Z" }, + { url = "https://files.pythonhosted.org/packages/36/f2/1130fad846f08bbf104a64232ef4f58ae5b5c4b2c64d6a73b1f4245607e0/primp-1.0.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6e756480c9dd585b20927c2a0c1d0c42cbcb5866ed1e741a8f93163e6f905e6c", size = 3440111, upload-time = "2026-02-13T15:32:57.523Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e5/a3e0ba7f4a0409ba615098bda35a1276ebf992d2bd7a8f635c8349e77276/primp-1.0.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b75a10ead2872dee9be9c60c07e8fce5328c88ed251e3fdbd29a7d2d73ab512a", size = 3651920, upload-time = "2026-02-13T15:32:48.511Z" }, + { url = "https://files.pythonhosted.org/packages/80/02/10cfc095e958e498171977068ebcabddaa8dabd7835725482b8c0eefec19/primp-1.0.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ea1a0b1d4c2a65efd5f22bc42bc0133ebf359f70dd155847cbebf8015fb05a1", size = 3922305, upload-time = "2026-02-13T15:33:23.231Z" }, + { url = "https://files.pythonhosted.org/packages/89/00/947c74646825d38d7f5c5fc5a7f2474f30767ea9817f9a7742f95ac99e45/primp-1.0.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1abd58a2bf0a2f062edc51a3684f8b9d0170348a96afdd3915f02f498c661228", size = 3811925, upload-time = "2026-02-13T15:33:04.976Z" }, + { url = "https://files.pythonhosted.org/packages/65/34/0f788310dd2903be8b49d9396ad4fa7deb1f5ab6419a2a7ea9014380f52f/primp-1.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52506249b8132eb386e90349f9fbbcf6b39e36523d61f92a0e8c557e32f71ef2", size = 4009948, upload-time = "2026-02-13T15:32:43.88Z" }, + { url = "https://files.pythonhosted.org/packages/44/35/9a3147377764380fa9940d4cfc328b5a31a1a1c72d2cbbdaa188ab8ea296/primp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b7f24c3a67aab0517ba4f6e743dfced331198062ff8e31df692381e60a17b775", size = 3970643, upload-time = "2026-02-13T15:33:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/396511a300bc44de4213198f10a21337fcb3f43e4553ece9a17b1a48e1df/primp-1.0.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0cf76f39d5820a2607a2dd25c074ceb8efa741bc311552218156c53b1002ec25", size = 3668236, upload-time = "2026-02-13T15:33:00.299Z" }, + { url = "https://files.pythonhosted.org/packages/2b/44/f1f4a6223dbfa8c72d37286b4bf9a2bb06241c9bac7ce95c5acc03069fec/primp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3414a4bbe37e909a45c0fea04104bd23165d81b94f3d68bfe9a11ba18c462b39", size = 3776956, upload-time = "2026-02-13T15:33:08.969Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9e/b6cb2c19abaeea0ade9256c296340b79dee0084bffcbaadceeebaf75c691/primp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3487e5269dc6d840035d59a8e5afbba99b5736da848664b71356681a837c3a8b", size = 4262036, upload-time = "2026-02-13T15:33:21.939Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/bf5a730384f338be7a52e5976c0f7ea8e00f8f078a80bd51fa15a61cd35a/primp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:0c44e8dccfcd2dd3fb3467d44836445039a013704ea869340bf67a444cbf3f36", size = 3185054, upload-time = "2026-02-13T15:33:15.486Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0b/92d644fbbf97f8fca2959c388f0ed50abd9ea1d17c3ad9b5b0e364fa8d37/primp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:705fb755f5461b551925de7546f3fea5b657fc44fee136498bed492bf5051864", size = 3512508, upload-time = "2026-02-13T15:32:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/efd595743e3b8b0477f44194f6a22fe0d7118b76e9b01167b0921a160d91/primp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:4e080ad054df4c325c434acf613d9cae54278e8141fa116452ec18bf576672a8", size = 3560136, upload-time = "2026-02-13T15:32:50.901Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/e3ee3836154f849086e5a29db7ec95bf805c0143266d59868c2eff0528df/primp-1.0.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6853b719f511ed09dc3673e54cd489b4ed35b0f769428dc79b3c54c446aafd22", size = 3890886, upload-time = "2026-02-13T15:33:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/4ea190b844557e919a84d3851d49407303d145dfe93cab67d2ed7268c6fa/primp-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3d072d1e3c84068b5727426500210e33241ef97844fe781d9817094fdfc6b128", size = 3653937, upload-time = "2026-02-13T15:33:13.803Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/bb861bcc45b6761b4dcc3b41a1ce6eecea9ccf4e9786d545f28313540259/primp-1.0.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ef28f8d6b89c5daf651dc7c7560b4914647bfe73b9a3847e2ae5ed0ff7d8bcf", size = 3792475, upload-time = "2026-02-13T15:33:27.419Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/f87d652aa13a1b1bba9f576c04732319ecf75075e3b26bf91ad47eab00d3/primp-1.0.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04a0d9d88cdce7ab685b4657cfe07d603a85118ec48a09015fa66eadad156c44", size = 3443247, upload-time = "2026-02-13T15:32:46.793Z" }, + { url = "https://files.pythonhosted.org/packages/31/f5/623885d04702523201639af3d011efb2eaed0dff9200a78db609b570c4c6/primp-1.0.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0ad2255403b155d93cf5cb7f6e807e26dc10c49071e0bac888c2c0e14801b82", size = 3651674, upload-time = "2026-02-13T15:33:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/0b/17/b45e7e79cf3c5de7aaf23bf38167243c4df017997d954dd903a479f474d8/primp-1.0.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3e7ccbe4746163f14b984523ac49ce3eed923fbe672c4c08480fa13217c2357", size = 3918929, upload-time = "2026-02-13T15:32:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/fb/00/f5f58ef9856d99cf52e59f9034b27dc2659430be3257ecb890f1b4fccb17/primp-1.0.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63a1d34732c2e6282e5e30f5d425eaa28ca417d74accda92908fdb8c944ff319", size = 3814485, upload-time = "2026-02-13T15:33:16.917Z" }, + { url = "https://files.pythonhosted.org/packages/b0/93/5e82f1fb2fd026d21c645b80da90f29f3afb6f1990120dcff8662c4f4b6e/primp-1.0.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e61f173e661ed8e21d8cd6534c586ad1d25565a0bac539a6a2d5e990439e0", size = 4014672, upload-time = "2026-02-13T15:33:26.083Z" }, + { url = "https://files.pythonhosted.org/packages/03/d7/6f1739043c84e772b45c51d2a1ab8c32727f0db6d41beb1b092a7baa2c02/primp-1.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcb28e07bc250b8c4762312e952bd84b6b983554fba6cd067f83018bd39a0488", size = 3971122, upload-time = "2026-02-13T15:32:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/47d7101034a36e73bb6976c566c56b54ec46efff1d64ebc07dccf05e51d8/primp-1.0.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8e5b8fa46130d3db33192784d4935fc3f9574f030d0e78d281e90c37cf2507ee", size = 3669273, upload-time = "2026-02-13T15:33:10.267Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/86878a9b46fc4bafba454e63b293e779c1ba6f9bf5ffc221f2f3dc70d60e/primp-1.0.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:984ab730449fd2e5f794fd6fad37fed3596432a24435ce2d0363b454503b7846", size = 3776747, upload-time = "2026-02-13T15:33:03.156Z" }, + { url = "https://files.pythonhosted.org/packages/9c/52/7afaf2a232987711863fa1e994cb6908c9dcd550d436578bb6cb63e53a83/primp-1.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2abd6d47ca60028bcc33dc47dd33f355237be80d7889518e44cc4d730c9e45e0", size = 4266058, upload-time = "2026-02-13T15:32:59.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/c2/fd1365ab28c4e15bebd291215c152c9787185a4fade0df780bb5e53d5866/primp-1.0.0-cp314-cp314t-win32.whl", hash = "sha256:39c27d84fd597a43bb291b6928fbaa46d4a7aff0c31ae1a361dccbbd109118a1", size = 3184230, upload-time = "2026-02-13T15:32:45.437Z" }, + { url = "https://files.pythonhosted.org/packages/30/2f/fcb4935ef1b2ba19bafbf050775f402ef30d19c9ba0d83a6328b453436a4/primp-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bc8bac0288fb7ed541c8db4be46c5f2779e4c1b023bf01e46fe4c1405150dbeb", size = 3514652, upload-time = "2026-02-13T15:33:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/49/88/2dbeee5a6c914c36b5dfca6e77913f4a190ac0137db0ea386b9632c16ef0/primp-1.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:117d3eb9c556fe88c8ed0533be80c2495922671e977e3e0e78a6b841014380eb", size = 3553319, upload-time = "2026-02-13T15:33:19.67Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[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.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "random-user-agent" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/29/7bfe8fec7002a62ebf3317af6c52251f229516d7ff405ea9b168f8417404/random_user_agent-1.0.1.tar.gz", hash = "sha256:8f8ca26ec8cb1d24ad1758d8b8f700d154064d641dbe9a255cfec42960fbd012", size = 8237117, upload-time = "2018-12-26T08:12:58.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/88/8a953b6f08d7cc709695be1a640cdd3a50996636e675381c2b3ec2d7ec44/random_user_agent-1.0.1-py3-none-any.whl", hash = "sha256:535636a55fb63fe3d74fd0260d854c241d9f2946447026464e578e68eac17dac", size = 8235140, upload-time = "2018-12-26T08:12:33.069Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/16/bfd13770be1acd1c05506b93fc6be15c759d6417595d1ba334d355efbf26/regex-2026.7.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341", size = 494639, upload-time = "2026-07-10T19:46:52.207Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/0086215709f0f705661f13ba81516287538886ef0d589c545c12b0484669/regex-2026.7.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1", size = 295920, upload-time = "2026-07-10T19:46:53.63Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9e/8e07d0eea46d2cf36bf4d3794634bb0a820f016d31bc349dfef008d96b02/regex-2026.7.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a", size = 290673, upload-time = "2026-07-10T19:46:54.863Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/d83c446de21c70ff49d2f1b2ff2196ac79a4ac6373d2cfe496011a250600/regex-2026.7.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17", size = 792378, upload-time = "2026-07-10T19:46:56.116Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0e/d265e0cc6da47aea97e90eb896be2d2e8f92d16add13bac04fa46a0fd972/regex-2026.7.10-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be", size = 861790, upload-time = "2026-07-10T19:46:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a5/62655f6208d1170a3e9188d6a45d4af0a5ae3b9da8b87d474818ac5ff016/regex-2026.7.10-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2", size = 906530, upload-time = "2026-07-10T19:46:59.142Z" }, + { url = "https://files.pythonhosted.org/packages/86/b7/d65aa2e9ffb18677cd0afbcf5990da8519a4e50778deb1bca49f043c5174/regex-2026.7.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b", size = 799912, upload-time = "2026-07-10T19:47:00.534Z" }, + { url = "https://files.pythonhosted.org/packages/8f/19/3a5ce23ea2eb1fe36306aef49c79746ce297e4b434aeb981b525c661413a/regex-2026.7.10-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa", size = 773675, upload-time = "2026-07-10T19:47:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/fe/76/3c0eaa426700dd2ba14f2335f2b700a4e1484202254192ae440b83b8352a/regex-2026.7.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561", size = 781711, upload-time = "2026-07-10T19:47:03.425Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a8/a5a3fad84f9a7f897619f0f8e0a2c64946e9709044a186a8f869fb5c332f/regex-2026.7.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f", size = 854539, upload-time = "2026-07-10T19:47:04.999Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c7/47e9b8c8ee77723b9eda74f517b6b25d2f555cf276c063a9eeea35bd86d5/regex-2026.7.10-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf", size = 763378, upload-time = "2026-07-10T19:47:06.845Z" }, + { url = "https://files.pythonhosted.org/packages/36/09/e27e42d9d42edf71205c7e6f5b2902bc874ea03c557c80da03b8ed16c9bf/regex-2026.7.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296", size = 844663, upload-time = "2026-07-10T19:47:08.923Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b5/2423acb98362184ad9c8eebabafa15188d6a177daab919add8f2120fc6cd/regex-2026.7.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e", size = 789236, upload-time = "2026-07-10T19:47:10.303Z" }, + { url = "https://files.pythonhosted.org/packages/60/ed/b387e84c8a3d6aa115dfb56865437a3fbaf28f4a6fb3b76cc6cce38ced70/regex-2026.7.10-cp311-cp311-win32.whl", hash = "sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a", size = 266774, upload-time = "2026-07-10T19:47:11.904Z" }, + { url = "https://files.pythonhosted.org/packages/c4/64/f30a163a65ed1f07ad12c53af00a6bd2a7251a5329fba5a08adc6f9e81a3/regex-2026.7.10-cp311-cp311-win_amd64.whl", hash = "sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c", size = 277959, upload-time = "2026-07-10T19:47:13.231Z" }, + { url = "https://files.pythonhosted.org/packages/2b/de/61c8174171134cebb834ca9f8fe2ff8f49d8a3dd43453b48b537d0fbb49b/regex-2026.7.10-cp311-cp311-win_arm64.whl", hash = "sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc", size = 276918, upload-time = "2026-07-10T19:47:14.693Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9c/2503d4ccf3452dc323f8baa3cf3ee10406037d52735c76cfced81423f183/regex-2026.7.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4", size = 497114, upload-time = "2026-07-10T19:47:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/04534f4263a4f658cd20a511e9d6124350044f2214eb24fee2db96acf318/regex-2026.7.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6", size = 297422, upload-time = "2026-07-10T19:47:17.794Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2d/35809de392ab66ba439b58c3187ae3b8b53c883233f284b59961e5725c99/regex-2026.7.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181", size = 292110, upload-time = "2026-07-10T19:47:19.188Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1e/5ce0fbe9aab071893ce2b7df020d0f561f7b411ec334124302468d587884/regex-2026.7.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38", size = 796800, upload-time = "2026-07-10T19:47:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/c1ccbada395c10e334763b583e1039b1660b142303ebb941d4269130b22f/regex-2026.7.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6", size = 865509, upload-time = "2026-07-10T19:47:22.135Z" }, + { url = "https://files.pythonhosted.org/packages/0e/06/f0b31afc16c1208f945b66290eb2a9936ab8becdfb23bbcedb91cc5f9d9b/regex-2026.7.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d", size = 912395, upload-time = "2026-07-10T19:47:24.128Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/8687de3a6c3220f4f872a9bf4bcd8dc249f2a96e7dddfa93de8bd4d16399/regex-2026.7.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f", size = 801308, upload-time = "2026-07-10T19:47:25.696Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e3/60a40ec02a2315d826414a125640aceb6f30450574c530c8f352110ece0e/regex-2026.7.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a", size = 777120, upload-time = "2026-07-10T19:47:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9a/ec579b4f840ac59bc7c192b56e66abd4cbf385615300d59f7c94bf6863ae/regex-2026.7.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68", size = 785164, upload-time = "2026-07-10T19:47:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1c/60d88afd5f98d4b0fb1f8b8969270628140dc01c7ff93a939f2aa83f31a6/regex-2026.7.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0", size = 860161, upload-time = "2026-07-10T19:47:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/08ae3ba45fe79e48c9a888a3389a7ee7e2d8c580d2d996da5ece02dfdcb9/regex-2026.7.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4", size = 765829, upload-time = "2026-07-10T19:47:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/e6/e613c6755d19aca9d977cdc3418a1991ffc8f386779752dd8fdfa888ea89/regex-2026.7.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402", size = 852170, upload-time = "2026-07-10T19:47:33.567Z" }, + { url = "https://files.pythonhosted.org/packages/03/33/89072f2060e6b844b4916d5bc40ef01e973640c703025707869264ec75ab/regex-2026.7.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb", size = 789550, upload-time = "2026-07-10T19:47:35.395Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/4bc8be9a155035e63780ccac1da101f36194946fdc3f6fce90c7179fc6df/regex-2026.7.10-cp312-cp312-win32.whl", hash = "sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d", size = 267151, upload-time = "2026-07-10T19:47:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/9f5aade65bb98cc6e99c336e45a49a658300720c16721f3e687f8d754fec/regex-2026.7.10-cp312-cp312-win_amd64.whl", hash = "sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f", size = 277751, upload-time = "2026-07-10T19:47:38.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/6f/d069dd12872ea1d50e17319d342f89e2072cae4b62f4245009a1108c74d8/regex-2026.7.10-cp312-cp312-win_arm64.whl", hash = "sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe", size = 277063, upload-time = "2026-07-10T19:47:40.023Z" }, + { url = "https://files.pythonhosted.org/packages/e0/88/0c977b9f3ba9b08645516eca236388c340f56f7a87054d41a187a04e134c/regex-2026.7.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c", size = 496868, upload-time = "2026-07-10T19:47:41.675Z" }, + { url = "https://files.pythonhosted.org/packages/f6/51/600882cd5d9a3cf083fd66a4064f5b7f243ba2a7de2437d42823e286edaf/regex-2026.7.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1", size = 297306, upload-time = "2026-07-10T19:47:43.521Z" }, + { url = "https://files.pythonhosted.org/packages/52/6f/48a912054ffcb756e374207bb8f4430c5c3e0ffa9627b3c7b6661844b30a/regex-2026.7.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d", size = 291950, upload-time = "2026-07-10T19:47:45.267Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c8/8e1c3c86ebcee7effccbd1f7fc54fe3af22aa0e9204503e2baea4a6ff001/regex-2026.7.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983", size = 796817, upload-time = "2026-07-10T19:47:48.054Z" }, + { url = "https://files.pythonhosted.org/packages/65/39/3e49d9ff0e0737eb8180a00569b47aabb59b84611f48392eba4d998d91a0/regex-2026.7.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197", size = 865513, upload-time = "2026-07-10T19:47:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/70/57/6511ad809bb3122c65bbeeffa5b750652bb03d273d29f3acb0754109b183/regex-2026.7.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e", size = 912391, upload-time = "2026-07-10T19:47:51.776Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/a1b0c109c9e878cb04b931bfe4c54332d692b93c322e127b5ae9f25b0d9e/regex-2026.7.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644", size = 801338, upload-time = "2026-07-10T19:47:53.38Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/171c3dad4d77000e1befeff2883ca88734696dfd97b2951e5e074f32e4dd/regex-2026.7.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e", size = 777149, upload-time = "2026-07-10T19:47:54.944Z" }, + { url = "https://files.pythonhosted.org/packages/33/61/41ab0de0e4574da1071c151f67d1eb9db3d92c43e31d64d2e6863c3d89bf/regex-2026.7.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8", size = 785216, upload-time = "2026-07-10T19:47:56.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/372859ea693736f07cf7023247c7eca8f221d9c6df8697ff9f93371cca08/regex-2026.7.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775", size = 860229, upload-time = "2026-07-10T19:47:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/50/b1/e1d32cd944b599534ae655d35e8640d0ec790c0fa12e1fb29bf434d50f55/regex-2026.7.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da", size = 765797, upload-time = "2026-07-10T19:48:00.291Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/79a2cd9556a3329351e370929743ef4f0ccc0aaff6b3dc414ae5fa4a1302/regex-2026.7.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1", size = 852130, upload-time = "2026-07-10T19:48:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/76fec29898cf5d359ab63face50f9d4f7135cc2eca3477139227b1d09952/regex-2026.7.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963", size = 789644, upload-time = "2026-07-10T19:48:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/f6/06/3c7cec7817bda293e13c8f88aed227bbcf8b37e5990936ff6442a8fdf11a/regex-2026.7.10-cp313-cp313-win32.whl", hash = "sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e", size = 267130, upload-time = "2026-07-10T19:48:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/e2a6f9a6a905f923cfc912298a5949737e9504b1ca24f29eda8d04d05ece/regex-2026.7.10-cp313-cp313-win_amd64.whl", hash = "sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927", size = 277722, upload-time = "2026-07-10T19:48:07.318Z" }, + { url = "https://files.pythonhosted.org/packages/00/a6/9d8935aaa940c388496aa1a0c82669cc4b5d06291c2712d595e3f0cf16d3/regex-2026.7.10-cp313-cp313-win_arm64.whl", hash = "sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08", size = 277059, upload-time = "2026-07-10T19:48:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e9/26decfd3e85c09e42ff7b0d23a6f51085ca4c268db15f084928ca33459c6/regex-2026.7.10-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b", size = 501508, upload-time = "2026-07-10T19:48:10.668Z" }, + { url = "https://files.pythonhosted.org/packages/38/a5/5b167cebde101945690219bf34361481c9f07e858a4f46d9996b80ec1490/regex-2026.7.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8", size = 299705, upload-time = "2026-07-10T19:48:12.544Z" }, + { url = "https://files.pythonhosted.org/packages/f6/20/7909be4b9f449f8c282c14b6762d59aa722aeaeebe7ee4f9bb623eeaa5e0/regex-2026.7.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc", size = 294605, upload-time = "2026-07-10T19:48:14.495Z" }, + { url = "https://files.pythonhosted.org/packages/82/88/e52550185d6fda68f549b01239698697de47320fd599f5e880b1986b7673/regex-2026.7.10-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200", size = 811747, upload-time = "2026-07-10T19:48:16.197Z" }, + { url = "https://files.pythonhosted.org/packages/06/98/16c255c909714de1ee04da6ae30f3ee04170f300cdc0dcf57a314ee4816a/regex-2026.7.10-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e", size = 871203, upload-time = "2026-07-10T19:48:18.12Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/423ed27c9bae2092a453e853da2b6628a658d08bb5a6117db8d591183d85/regex-2026.7.10-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8", size = 917334, upload-time = "2026-07-10T19:48:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/87/74dac8efb500db31cb000fda6bae2be45fc2fbf1fa9412f445fbb8acbe37/regex-2026.7.10-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e", size = 816379, upload-time = "2026-07-10T19:48:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/1859403654e3e030b288f06d49233c6a4f889d62b84c4ef3f3a28653173d/regex-2026.7.10-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6", size = 785563, upload-time = "2026-07-10T19:48:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/35d30d6bdf1ef6a5430e8982607b3a6db4df1ddedbe001e43435585d88ba/regex-2026.7.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192", size = 801415, upload-time = "2026-07-10T19:48:25.499Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/630f31f5ea4826167b2b064d9cac2093a5b3222af380aa432cfe1a5dabcd/regex-2026.7.10-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851", size = 866560, upload-time = "2026-07-10T19:48:27.789Z" }, + { url = "https://files.pythonhosted.org/packages/8d/14/f5914a6d9c5bc63b9bed8c9a1169fb0be35dbe05cdc460e17d953031a366/regex-2026.7.10-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812", size = 772877, upload-time = "2026-07-10T19:48:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/0f/7c13999eef3e4186f7c79d4950fa56f041bf4de107682fb82c80db605ff9/regex-2026.7.10-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef", size = 856648, upload-time = "2026-07-10T19:48:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/a4/71/a48e43909b6450fb48fa94e783bef2d9a37179258bc32ef2283955df7be7/regex-2026.7.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682", size = 803520, upload-time = "2026-07-10T19:48:33.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/f037d1bf2c133cb24ceb6e7d81d08417080390eddab6ddfd701aa7091874/regex-2026.7.10-cp313-cp313t-win32.whl", hash = "sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1", size = 269168, upload-time = "2026-07-10T19:48:35.353Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/eaac34f8452a838956e7e89852ad049678cdc1af5d14f72d3b3b658b1ea5/regex-2026.7.10-cp313-cp313t-win_amd64.whl", hash = "sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344", size = 280004, upload-time = "2026-07-10T19:48:37.106Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a9/e22e997587bc1d588b0b2cd0572027d39dd3a006216e40bbf0361688c51c/regex-2026.7.10-cp313-cp313t-win_arm64.whl", hash = "sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837", size = 279308, upload-time = "2026-07-10T19:48:38.907Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4a/a7fa3ada9bd2d2ce20d56dfceec6b2a51afeed9bf3d8286355ceec5f0628/regex-2026.7.10-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca", size = 497087, upload-time = "2026-07-10T19:48:40.543Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7e/ca0b1a87192e5828dbc16f16ae6caca9b67f25bf729a3348468a5ff52755/regex-2026.7.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042", size = 297307, upload-time = "2026-07-10T19:48:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/fb40bb34275d3cd4d7a376d5fb2ea1f0f4a96fd884fa83c0c4ae869001bf/regex-2026.7.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be", size = 292163, upload-time = "2026-07-10T19:48:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/34cbea16c8fea9a18475a7e8f5837c70af451e738bfeb4eb5b029b7dc07a/regex-2026.7.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6", size = 797064, upload-time = "2026-07-10T19:48:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/77/f6805d97f15f5a710bdfd56a768f3468c978239daf9e1b15efd8935e1967/regex-2026.7.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89", size = 866155, upload-time = "2026-07-10T19:48:47.589Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e3/a2a905807bba3bcd90d6ebbb67d27af2adf7d41708175cbc6b956a0c75f1/regex-2026.7.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794", size = 911596, upload-time = "2026-07-10T19:48:49.473Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/a3126888b2c6f33c7e29144fedf85f6d5a52a400024fa045ad8fc0550ef1/regex-2026.7.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c", size = 800713, upload-time = "2026-07-10T19:48:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/66/19/9d252fd969f726c8b56b4bacf910811cc70495a110907b3a7ccb96cd9cad/regex-2026.7.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361", size = 777286, upload-time = "2026-07-10T19:48:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/5f1bf433fa446ecb3aab87bb402603dc9e171ef8052c1bb8690bb4e255a3/regex-2026.7.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3", size = 785826, upload-time = "2026-07-10T19:48:55.381Z" }, + { url = "https://files.pythonhosted.org/packages/99/ca/69f3a7281d86f1b592338007f3e535cc219d771448e2b61c0b56e4f9d05b/regex-2026.7.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90", size = 860957, upload-time = "2026-07-10T19:48:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/487ff55c8d515ec9dd60d7ba3c129eeaa9e527358ed9e8a054a9e9430f81/regex-2026.7.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6", size = 765959, upload-time = "2026-07-10T19:49:00.27Z" }, + { url = "https://files.pythonhosted.org/packages/73/e1/fa034e6fa8896a09bd0d5e19c81fdc024411ab37980950a0401dccee8f6d/regex-2026.7.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06", size = 851447, upload-time = "2026-07-10T19:49:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/b9427ed53b0e14c540dc436d56aaf57a19fb9183c6e7abd66f4b4368fbad/regex-2026.7.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8", size = 789418, upload-time = "2026-07-10T19:49:03.949Z" }, + { url = "https://files.pythonhosted.org/packages/ba/52/aab92420c8aa845c7bcbe68dc65023d4a9e9ea785abf0beb2198f0de5ba1/regex-2026.7.10-cp314-cp314-win32.whl", hash = "sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2", size = 272538, upload-time = "2026-07-10T19:49:05.833Z" }, + { url = "https://files.pythonhosted.org/packages/99/16/5c7050e0ef7dd8889441924ff0a2c33b7f0587c0ccb0953fe7ca997d673b/regex-2026.7.10-cp314-cp314-win_amd64.whl", hash = "sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43", size = 280796, upload-time = "2026-07-10T19:49:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1a/4f6099d2ba271502fdb97e697bae2ed0213c0d87f2273fe7d21e2e401d12/regex-2026.7.10-cp314-cp314-win_arm64.whl", hash = "sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5", size = 281017, upload-time = "2026-07-10T19:49:09.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/02/4061fc71f64703e0df61e782c2894c3fbc089d277767eff6e16099581c73/regex-2026.7.10-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49", size = 501467, upload-time = "2026-07-10T19:49:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/8d42b2f3fd672908a05582effd0f88438bf9bb4e8e02d69a62c723e23601/regex-2026.7.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f", size = 299700, upload-time = "2026-07-10T19:49:14.067Z" }, + { url = "https://files.pythonhosted.org/packages/65/70/36fa4b46f73d268c0dbe77c40e62da2cd4833ee206d3b2e438c2034e1f36/regex-2026.7.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba", size = 294590, upload-time = "2026-07-10T19:49:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/b6db1823f3a233c2a46f854fdc986f4fd424a84ed557b7751f2998efb266/regex-2026.7.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2", size = 811925, upload-time = "2026-07-10T19:49:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7d/f8bee4c210c42c7e8b952bb9fb7099dd7fb2f4bd0f33d0d65a8ab08aafc0/regex-2026.7.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067", size = 871257, upload-time = "2026-07-10T19:49:19.943Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/22adf72e614ba0216b996e9aaef5712c23699e360ea127bb3d5ee1a7666f/regex-2026.7.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478", size = 917551, upload-time = "2026-07-10T19:49:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/03/f7/ebc15a39e81e6b58da5f913b91fc293a25c6700d353c14d5cd25fc85712a/regex-2026.7.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c", size = 816436, upload-time = "2026-07-10T19:49:24.131Z" }, + { url = "https://files.pythonhosted.org/packages/5c/33/20bc2bdd57f7e0fcc51be37e4c4d1bca7f0b4af8dc0a148c23220e689da8/regex-2026.7.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70", size = 785935, upload-time = "2026-07-10T19:49:26.265Z" }, + { url = "https://files.pythonhosted.org/packages/b4/51/87ff99c849b56309c40214a72b54b0eef320d0516a8a516970cc8be1b725/regex-2026.7.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f", size = 801494, upload-time = "2026-07-10T19:49:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/16/11/fde67d49083fef489b7e0f841e2e5736516795b166c9867f05956c1e494b/regex-2026.7.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173", size = 866549, upload-time = "2026-07-10T19:49:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/31a156c36acf10181d88f55a66c688d5454a344e53ccc03d49f4a48a2297/regex-2026.7.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48", size = 773089, upload-time = "2026-07-10T19:49:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/27/bb/734e978c904726664df47ae36ce5eca5065de5141185ae46efec063476a2/regex-2026.7.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d", size = 856710, upload-time = "2026-07-10T19:49:35.289Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e5/dc35cea074dbdcb9776c4b0542a3bc326ff08454af0768ef35f3fc66e7fa/regex-2026.7.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be", size = 803621, upload-time = "2026-07-10T19:49:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/124564af46bc0b592785610b3985315610af0a07f4cf21fa36e06c2398dd/regex-2026.7.10-cp314-cp314t-win32.whl", hash = "sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca", size = 274558, upload-time = "2026-07-10T19:49:39.926Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/cd813ce9f3404c0443915175c1e339c5afd8fcda04310102eaf233015eef/regex-2026.7.10-cp314-cp314t-win_amd64.whl", hash = "sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb", size = 283687, upload-time = "2026-07-10T19:49:41.872Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d3/3dae6a6ce46144940e64425e32b8573a393a009aeaf75fa6752a35399056/regex-2026.7.10-cp314-cp314t-win_arm64.whl", hash = "sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3", size = 283377, upload-time = "2026-07-10T19:49:43.985Z" }, +] + +[[package]] +name = "requests" +version = "2.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/36/7180e7f077c38108945dbbdf60fe04db681c3feb6e96419f8c6dc8723741/requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb", size = 142783, upload-time = "2026-05-13T19:20:24.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/5a/4a949d170476de3c04ac036b5466422fbcbf348a917d8042eedf2cac7d1b/requests-2.34.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0", size = 73085, upload-time = "2026-05-13T19:20:22.827Z" }, +] + +[[package]] +name = "requests-cache" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "url-normalize" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/be/7b2a95a9e7a7c3e774e43d067c51244e61dea8b120ae2deff7089a93fb2b/requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1", size = 3018209, upload-time = "2024-06-18T17:18:03.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/2e/8f4051119f460cfc786aa91f212165bb6e643283b533db572d7b33952bd2/requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603", size = 61425, upload-time = "2024-06-18T17:17:45Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "sgmllib3k" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } + +[[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 = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[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 = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[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 = "tld" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "trafilatura" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "courlan" }, + { name = "htmldate" }, + { name = "justext" }, + { name = "lxml" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/19/24833e905df2d80e3bb67424f95febcc17709a1f61a522120bc438afca70/trafilatura-2.1.0.tar.gz", hash = "sha256:f689e2116fc89c7bc0b9a296d01dcfe2eb0b5455f8c371a77dc0db1f06a05643", size = 263876, upload-time = "2026-06-07T17:43:31.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/78/4ad99d79aee2784f49f20fd0a29058ce4c032fe4439047924c43521cd211/trafilatura-2.1.0-py3-none-any.whl", hash = "sha256:0eded5207a806445ddebbe36eae30b9035fe6a2f233c36f6fe82663fca8b9d30", size = 134600, upload-time = "2026-06-07T17:43:28.404Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, +] + +[[package]] +name = "url-normalize" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/ea/780a38c99fef750897158c0afb83b979def3b379aaac28b31538d24c4e8f/url-normalize-1.4.3.tar.gz", hash = "sha256:d23d3a070ac52a67b83a1c59a0e68f8608d1cd538783b401bc9de2c0fac999b2", size = 6024, upload-time = "2020-10-26T02:07:11.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/1c/6c6f408be78692fc850006a2b6dea37c2b8592892534e09996e401efc74b/url_normalize-1.4.3-py2.py3-none-any.whl", hash = "sha256:ec3c301f04e5bb676d333a7fa162fa977ad2ca04b7e652bfc9fac4e405728eed", size = 6804, upload-time = "2020-10-26T02:07:13.218Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uuid7" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/19/7472bd526591e2192926247109dbf78692e709d3e56775792fec877a7720/uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c", size = 14052, upload-time = "2021-12-29T01:38:21.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/77/8852f89a91453956582a85024d80ad96f30a41fed4c2b3dce0c9f12ecc7e/uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61", size = 7477, upload-time = "2021-12-29T01:38:20.418Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "xmltodict" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/0d/40df5be1e684bbaecdb9d1e0e40d5d482465de6b00cbb92b84ee5d243c7f/xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56", size = 33813, upload-time = "2022-05-08T07:00:04.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/db/fd0326e331726f07ff7f40675cd86aa804bfd2e5016c727fa761c934990e/xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852", size = 9971, upload-time = "2022-05-08T07:00:02.898Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "yfinance" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/fd/943a7d71ce98a40b9006daccba96a83837acadb8e55361f41c7a81873013/yfinance-1.3.0.tar.gz", hash = "sha256:42c4e64a889dab8eeaffd3a66d4ccf1baffd566910ca63fb6332283f8f9b8a40", size = 145297, upload-time = "2026-04-16T19:51:05.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bc/e46ed5dfb88c6f7af0f641ffb6227d32f484ea989a2987a52a9c35d17aa9/yfinance-1.3.0-py2.py3-none-any.whl", hash = "sha256:c89539f0cf6af026d570131189bd659a962e8fb942376ef8ff8913e77c9fca75", size = 133706, upload-time = "2026-04-16T19:51:04.298Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +]