feat(rubick): rename project and add production release bundle

This commit is contained in:
Muhammad Firas 2026-03-06 03:12:28 +07:00
commit fab6cf26eb
No known key found for this signature in database
33 changed files with 11554 additions and 0 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
BRAVE_API_KEY=your-brave-api-key-here

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Local env
.env
.env.*
!.env.example
.python-version
# OS/editor
.DS_Store
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Go
.gocache/
/bin/
*.test
*.out
# Local binaries
rubick
msn-scraper
news-scraper
# Generated outputs
dist/
output/
*.db
*.db-shm
*.db-wal
output_*.json
screener_*.json
stocks_*.json
test_*.json
*.har
network_*.txt
response.html

49
Makefile Normal file
View file

@ -0,0 +1,49 @@
APP_NAME := rubick
BIN_DIR := bin
APP_BIN := $(BIN_DIR)/$(APP_NAME)
.PHONY: help setup build run test test-go test-py test-live e2e release clean
help:
@echo "Targets:"
@echo " setup Install Go/Python dependencies"
@echo " build Build CLI binary into ./bin"
@echo " run Run compiled binary: make run ARGS='msn lookup BBCA'"
@echo " test Run Go and Python tests"
@echo " test-go Run Go tests"
@echo " test-py Run Python tests"
@echo " test-live Run live network tests (requires .env and RUN_LIVE_E2E=1)"
@echo " e2e Timestamped deterministic e2e run under output/<timestamp>"
@echo " release Build distributable bundle under dist/ (VERSION=vX.Y.Z make release)"
@echo " clean Remove local build artifacts"
setup:
go mod download
uv sync
build:
mkdir -p $(BIN_DIR)
go build -o $(APP_BIN) ./cmd/rubick
run: build
./$(APP_BIN) $(ARGS)
test: test-go test-py
test-go:
go test -v ./...
test-py:
uv run python -m unittest discover -s tests -p 'test_*.py'
test-live:
set -a; source .env; set +a; RUN_LIVE_E2E=1 go test -v ./tests/go -run TestLive
e2e: build
set -a; source .env; set +a; bash scripts/e2e_run.sh ./$(APP_BIN)
release:
bash scripts/release_bundle.sh $(VERSION)
clean:
rm -rf $(BIN_DIR)

784
README.md Normal file
View file

@ -0,0 +1,784 @@
# Rubick
Enterprise-ready, multi-language CLI platform for Indonesian market intelligence.
This repository combines:
- high-throughput data collection from MSN Finance (Go),
- news discovery from Brave Search (Go),
- article-body extraction using `newspaper` (Python),
- structured export to JSON/CSV/XLSX (Python),
- SQLite persistence for repeatable historical analysis.
The system is built as one unified CLI with clear subcommands and strongly typed runtime boundaries so you can extend it safely without scattering code.
## Why This Project Exists
The goal is to solve a practical workflow problem:
- collect Indonesian stock fundamentals and snapshots,
- enrich stock context with real article text instead of headline-only snippets,
- persist data in a durable local store,
- export clean artifacts for analysis, reporting, and downstream automation.
Common use cases:
- analyst daily market snapshots,
- watchlist intelligence pipelines,
- historical trend extraction and spreadsheet exports,
- scheduled ETL feeding BI/ML workloads.
## Core Capabilities
- Unified CLI (`rubick`) with domain commands: `msn`, `news`, `export`, `extractor`.
- Live stock metadata and multi-endpoint stock fetch pipelines.
- Controlled concurrency and rate limiting for network-bound tasks.
- Python extractor process connected over Unix socket for fast IPC and language isolation.
- SQLite-based persistence for resumable scraping runs.
- Multiple export formats for both machine and human consumption.
- Regression tests and live API e2e test coverage.
## High-Level Architecture
```mermaid
flowchart LR
U[User / Scheduler] --> C[Unified CLI<br/>Go]
C --> M[MSN Workflows<br/>Go]
C --> N[News Workflow<br/>Go]
C --> E[Export Workflow<br/>Python]
M --> MSNAPI[MSN Finance APIs]
M --> DB[(SQLite)]
N --> BRAVE[Brave News API]
N --> FETCH[HTML Fetcher]
FETCH --> IPC[Unix Socket IPC]
IPC --> PX[Python Extractor<br/>newspaper]
N --> OUTJSON[News JSON Output]
E --> DB
E --> XLSX[XLSX / CSV / JSON Export Files]
```
## Runtime Data Flow
### 1. MSN bulk ingestion (`msn fetch-all`)
```mermaid
sequenceDiagram
participant User
participant CLI as Go CLI
participant DB as SQLite
participant MSN as MSN API
User->>CLI: msn fetch-all --index idx30 --db output/run/stocks.db
CLI->>DB: start scrape_run + init progress
loop each stock (concurrency workers)
CLI->>MSN: fetch stock endpoints
MSN-->>CLI: payloads + statuses
CLI->>DB: save stock + history + news + ratios
CLI->>DB: update progress row
end
CLI->>DB: complete scrape_run
CLI-->>User: progress + summary
```
### 2. News enrichment (`news`)
```mermaid
sequenceDiagram
participant User
participant CLI as Go CLI
participant Brave as Brave API
participant Web as Article URL
participant Py as Python Extractor
participant Out as JSON File
User->>CLI: news "IHSG" --count 20
CLI->>Brave: search news
Brave-->>CLI: URL list
CLI->>Py: start extractor process + socket pool
loop each URL (workers)
CLI->>Web: GET page HTML
CLI->>Py: extract(url, html)
Py-->>CLI: text/status
end
CLI->>Out: write enriched JSON
CLI-->>User: extraction summary
```
## Repository Structure and Folder Contracts
```text
.
├── cmd/
│ └── rubick/ # canonical binary entrypoint
├── internal/
│ └── cli/ # root command router + command implementations
├── msn/ # MSN domain library: client, db, types, limiter, stock universe
├── scripts/ # Python export scripts
├── tests/
│ ├── go/ # Go CLI regression + live network e2e tests
│ └── test_export_simple.py # Python exporter tests
├── extractor.py # Python unix-socket extraction service
├── main.go # compatibility entrypoint for local dev only
├── pyproject.toml # Python deps and tooling
├── go.mod / go.sum # Go module deps
└── README.md
```
Folder responsibilities:
| Path | Responsibility | Extension Rule |
|---|---|---|
| `cmd/rubick` | binary entrypoint only | keep thin; no business logic |
| `internal/cli` | command parsing, orchestration, runtime coordination | add new command handlers here |
| `msn` | data-source domain package and persistence internals | keep API/service-specific logic here |
| `scripts` | Python export implementations | each exporter should be independent CLI |
| `tests/go` | end-to-end and CLI behavior tests | test via public CLI behavior, not internals |
| `tests` | Python unit tests | one test module per script/component |
## Language Boundary (Go + Python)
Go is used for:
- CLI UX,
- concurrency and throughput,
- networking and orchestration,
- SQLite ingestion flows.
Python is used for:
- article extraction where ecosystem libraries are stronger,
- export formatting (especially XLSX convenience).
IPC contract:
- transport: Unix domain socket,
- framing: 4-byte big-endian length + JSON payload,
- request shape: `{ "url": string, "html": string }`,
- response shape: `{ "text": string, "status": "ok|failed", "error"?: string }`.
Reliability safeguards already implemented:
- per-run unique socket path to prevent collision,
- deadline-aware socket calls,
- full-frame read/write (`ReadFull` semantics),
- unhealthy pooled connections are closed/replaced,
- extractor process group termination and socket file cleanup.
## Getting Started
## Prerequisites
- Go `1.25+`
- Python `3.12+`
- `uv` for Python environment/dependency management
## Install
```bash
make setup
make build
```
Manual equivalent:
```bash
go mod download
uv sync
mkdir -p bin
go build -o bin/rubick ./cmd/rubick
```
## Environment
Create `.env` for Brave-powered news commands:
```bash
cp .env.example .env
# edit .env
# BRAVE_API_KEY=your_key_here
```
## Running the CLI
```bash
# production/dev standard
./bin/rubick <command> [options]
```
Developer-only shortcut (not recommended for operational runbooks):
```bash
go run ./cmd/rubick <command> [options]
```
## Command Catalog
Root commands:
| Command | Purpose | Output |
|---|---|---|
| `msn` | MSN finance workflows (`screener`, `fetch`, `fetch-all`, `lookup`) | JSON + SQLite |
| `news` | Brave search + article extraction | JSON |
| `export` | Python exports (`dashboard`, `history`, `simple`) | XLSX/CSV/JSON |
| `extractor` | run extractor service directly (advanced/debug) | socket server |
## Command Reference (Detailed)
### `msn screener`
Find stocks using preset screener filters.
```bash
./bin/rubick msn screener --region id --filter top-performers --limit 20 --output output/screener.json
```
Arguments:
| Flag | Expected Value | Default | Validation | What It Does |
|---|---|---|---|---|
| `--region` | region code (`id`) | `id` | non-empty | target market region for screener query |
| `--filter` | one preset from list below | `large-cap` | must map to known filter | selects screener criteria |
| `--limit` | integer `>= 1` | `50` | strict integer and min bound | max rows returned |
| `--output`, `-o` | file path | `screener_YYYYMMDD.json` | writable path | output JSON location |
Supported filter presets:
- `top-performers`
- `worst-performers`
- `high-dividend`
- `low-pe`
- `52w-high`
- `52w-low`
- `high-volume`
- `large-cap`
Expected output example:
```json
{
"filter": "top-performers",
"region": "id",
"generated_at": "2026-03-06T02:39:47Z",
"total": 20,
"stocks": [
{
"id": "bn91jc",
"symbol": "BBCA",
"name": "Bank Cntrl Asia",
"price": 9000.0,
"price_change_pct": 1.2
}
]
}
```
Variations:
```bash
# Small deterministic sample
./bin/rubick msn screener --region id --filter large-cap --limit 3 -o output/run/screener_top3.json
# Alternate filter
./bin/rubick msn screener --filter low-pe --limit 50
```
### `msn lookup`
Resolve ticker symbols to internal MSN IDs.
```bash
./bin/rubick msn lookup BBCA BBRI TLKM
```
Arguments:
| Input | Expected Value | What It Does | Output |
|---|---|---|---|
| positional tickers | uppercase ticker symbols | maps tickers to static IDX dictionary | table printed to stdout |
Example output:
```text
Ticker MSN ID Company Name
--------------------------------------------------
BBCA bn91jc Bank Cntrl Asia
BBRI bn6wly Bank Rakyat Indonesia
TLKM bn4k6h Telkom Indonesia
--------------------------------------------------
Found 3/3 tickers
```
### `msn fetch`
Fetch comprehensive stock data for specific IDs/tickers or screener input.
```bash
./bin/rubick msn fetch --tickers BBCA,BBRI,TLKM --concurrency 5 --output output/fetch.json
```
Arguments:
| Flag | Expected Value | Default | Validation | What It Does |
|---|---|---|---|---|
| `--input` | path to screener JSON | none | file must exist and parse | imports stock IDs from screener output |
| `--ids` | comma-separated IDs | none | non-empty entries | fetch by explicit MSN IDs |
| `--tickers` | comma-separated tickers | none | unknown tickers skipped with warning | resolves ticker to MSN ID |
| `--concurrency` | integer `>= 1` | `5` | strict integer and min bound | worker parallelism |
| `--output`, `-o` | file path | `stocks_YYYYMMDD.json` | writable path | output JSON |
Behavior notes:
- you must provide at least one source of IDs (`--input`, `--ids`, `--tickers`),
- duplicate IDs are deduplicated before fetch,
- fetch status is tracked per API subsection.
Expected output (truncated):
```json
{
"generated_at": "2026-03-06T02:40:00Z",
"total": 2,
"stocks": [
{
"id": "bn91jc",
"symbol": "BBCA",
"fetch_status": {
"quote": "ok",
"profile": "ok",
"financials": "ok"
}
}
]
}
```
Variations:
```bash
# By explicit IDs
./bin/rubick msn fetch --ids bn91jc,bn6wly -o output/run/fetch_ids.json
# From screener output
./bin/rubick msn fetch --input output/run/screener_top3.json --concurrency 2 -o output/run/fetch_from_screener.json
```
### `msn fetch-all`
Bulk ingest index constituents into SQLite with progress tracking.
```bash
./bin/rubick msn fetch-all --index idx30 --db output/stocks.db --rps 20 --delay 100-500 --concurrency 3
```
Arguments:
| Flag | Expected Value | Default | Validation | What It Does |
|---|---|---|---|---|
| `--db` | sqlite file path | `output/stocks.db` | writable path | target DB |
| `--index` | `all` / `lq45` / `idx30` / `idx80` | `all` | must be known, unknown falls back to all | stock universe scope |
| `--proxy` | proxy URL | empty | URL format checked by client path | route requests through proxy |
| `--concurrency` | integer `>= 1` | `5` | strict integer | worker count |
| `--rps` | float `> 0` | `25` | strict positive | global request throttling |
| `--delay` | `min-max` milliseconds | `100-500` | `0 <= min <= max` | jitter between requests |
| `--retry` | integer `>= 0` | `2` | strict integer | retry attempts per stock |
| `--limit` | integer `>= 0` | `0` (all) | strict integer | process first N stocks |
| `--resume` | no value | off | flag | continue incomplete run |
What it writes:
- `stocks`
- `price_history`
- `ratios_history`
- `news`
- `sentiment_history`
- `scrape_runs`
- `scrape_progress`
Expected terminal progress:
```text
Fetch-All Configuration:
Database: output/run/stocks.db
Index: idx30
Concurrency: 2 workers
Rate limit: 10.0 req/sec
Delay: 100-150 ms
Started run #1
Pending: 3 stocks to process
[1/3] ADRO - 8 APIs succeeded
[2/3] ASII - 8 APIs succeeded
[3/3] GOTO - 8 APIs succeeded
=== Run #1 Completed ===
```
Variations:
```bash
# Deterministic mini run for test
./bin/rubick msn fetch-all --index idx30 --limit 3 --db output/run/stocks.db --rps 10 --delay 100-150 --concurrency 2
# Resume interrupted batch
./bin/rubick msn fetch-all --index idx80 --db output/prod/stocks.db --resume
```
### `news`
Search Brave News and extract full article text from each result.
```bash
./bin/rubick news "IHSG stock market" --from 2026-03-01 --to 2026-03-05 --count 20 --concurrency 10 --output output/news.json
```
Arguments:
| Flag | Expected Value | Default | Validation | What It Does |
|---|---|---|---|---|
| positional `<query>` | free-text query | required | non-empty | base search query |
| `--from` | `YYYY-MM-DD` | now - 7d | valid date | start date |
| `--to` | `YYYY-MM-DD` | today | valid date and `from <= to` | end date |
| `--count` | integer `>= 1` | `20` | strict integer | result count requested |
| `--concurrency` | integer `>= 1` | `10` | strict integer | worker count for fetch/extract |
| `--output`, `-o` | json path | `output_YYYYMMDD.json` | writable path | final enriched output |
| `--stock` | no value | off | flag | transforms comma terms into IDX-centric boolean query |
Environment:
| Variable | Required | Used By | Purpose |
|---|---|---|---|
| `BRAVE_API_KEY` | yes for `news` | Go brave client | authenticate Brave Search API calls |
Expected output sample:
```json
{
"query": "IHSG",
"generated_at": "2026-03-06T02:39:47Z",
"results": [
{
"title": "...",
"url": "https://...",
"description": "...",
"page_age": "1d",
"text": "full extracted body text ...",
"fetch_status": "ok",
"extract_status": "ok"
}
]
}
```
Variations:
```bash
# Plain query
./bin/rubick news IHSG --count 5 -o output/run/news_plain.json
# Stock-mode query builder
./bin/rubick news "BBCA,Bank Central Asia" --stock --from 2026-03-01 --to 2026-03-05 --count 10 -o output/run/news_stock.json
# Lower concurrency for constrained hosts
./bin/rubick news IHSG --count 10 --concurrency 2
```
### `export dashboard`
Create dashboard-oriented workbook from SQLite.
```bash
./bin/rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
```
Arguments:
| Flag | Expected Value | Required | Purpose |
|---|---|---|---|
| `--db` | SQLite path | yes | source database |
| `--output` | `.xlsx` path | yes | generated workbook |
### `export history`
Create history-oriented workbook from SQLite.
```bash
./bin/rubick export history --db output/stocks.db --output output/history.xlsx
```
Arguments:
| Flag | Expected Value | Required | Purpose |
|---|---|---|---|
| `--db` | SQLite path | yes | source database |
| `--output` | `.xlsx` path | yes | generated workbook |
### `export simple`
Lightweight table export for automation and quick inspection.
```bash
./bin/rubick export simple --db output/stocks.db --format csv --output output/simple_csv
```
Arguments:
| Flag | Expected Value | Required | What It Does |
|---|---|---|---|
| `--db` | SQLite path | yes | source DB |
| `--format` | `json` / `csv` / `xlsx` | yes | output encoding |
| `--output`, `-o` | directory (json/csv) or file (xlsx) | yes | destination |
| `--tables` | comma-separated table list | no | export subset |
Default table set:
- `stocks`
- `price_history`
- `ratios_history`
- `news`
- `sentiment_history`
- `scrape_runs`
- `scrape_progress`
Variations:
```bash
# JSON folder export
./bin/rubick export simple --db output/run/stocks.db --format json --output output/run/simple_json
# XLSX single workbook
./bin/rubick export simple --db output/run/stocks.db --format xlsx --output output/run/simple.xlsx
# Table subset
./bin/rubick export simple --db output/run/stocks.db --format csv --tables stocks,news --output output/run/simple_subset
```
### `extractor` (advanced)
Run Python extractor server directly for debugging/local integration.
```bash
./bin/rubick extractor --socket /tmp/extractor.sock
```
Arguments:
| Flag | Expected Value | Required | Purpose |
|---|---|---|---|
| `--socket` | unix socket path | yes | bind location |
## Deterministic End-to-End Run (Timestamped)
Use this for repeatable smoke validation and artifact capture:
```bash
TS=$(date +%Y%m%d-%H%M%S)
mkdir -p output/$TS
# 1) Small stock ingestion
./bin/rubick msn fetch-all --index idx30 --limit 3 --db output/$TS/stocks.db --rps 10 --delay 100-150 --concurrency 2
# 2) News extraction
./bin/rubick news IHSG --from 2026-03-01 --to 2026-03-05 --count 2 --concurrency 2 --output output/$TS/news.json
# 3) Exports
./bin/rubick export simple --db output/$TS/stocks.db --format json --output output/$TS/simple_json
./bin/rubick export simple --db output/$TS/stocks.db --format csv --output output/$TS/simple_csv
./bin/rubick export simple --db output/$TS/stocks.db --format xlsx --output output/$TS/simple.xlsx
./bin/rubick export dashboard --db output/$TS/stocks.db --output output/$TS/dashboard.xlsx
./bin/rubick export history --db output/$TS/stocks.db --output output/$TS/history.xlsx
```
## Testing Strategy
### Go tests
```bash
# Full suite (includes tests/go)
go test -v ./...
# Focused CLI regression + live tests
go test -v ./tests/go
```
For CI and local repeatability, prefer:
```bash
make test
```
### Live network e2e tests
```bash
set -a; source .env; set +a
RUN_LIVE_E2E=1 go test -v ./tests/go -run TestLive
```
Behavior note:
- tests treat transient network errors (DNS timeout, temporary connectivity, 429) as skippable for live-only coverage.
### Python tests
```bash
uv run python -m unittest discover -s tests -p 'test_*.py'
```
## Release Bundle
Create a distributable artifact that includes:
- compiled `rubick` binary,
- `extractor.py`,
- Python export scripts (`scripts/export_*.py`),
- `pyproject.toml` and `uv.lock`,
- `.env.example`, `README.md`, and install instructions.
```bash
# auto version from git tag/commit
make release
# explicit version
VERSION=v1.0.0 make release
```
Output artifacts:
- `dist/rubick_<version>_<os>_<arch>/`
- `dist/rubick_<version>_<os>_<arch>.tar.gz`
- `dist/rubick_<version>_<os>_<arch>.zip`
Bundle runtime setup:
```bash
cd dist/rubick_<version>_<os>_<arch>
uv sync --frozen
./bin/rubick --help
```
## Build and Run Profiles
| Profile | Command | When To Use |
|---|---|---|
| Local developer iteration | `go run ./cmd/rubick ...` | rapid code changes before rebuilding |
| Normal local/CI usage | `./bin/rubick ...` | default path for scripts and tests |
| Release artifact | `go build -o bin/rubick ./cmd/rubick` | reproducible deployable binary |
## Automation Targets
`Makefile` commands:
| Target | Action |
|---|---|
| `make setup` | install Go + Python dependencies |
| `make build` | compile `bin/rubick` |
| `make run ARGS='...'` | run compiled binary with arguments |
| `make test` | run Go + Python tests |
| `make test-live` | run live e2e tests with `.env` |
| `make e2e` | run deterministic timestamped end-to-end workflow |
## Operational Characteristics
### Performance
- `msn fetch-all` throughput controlled by:
- worker count (`--concurrency`),
- global RPS (`--rps`),
- jitter (`--delay`),
- retries (`--retry`).
- `news` throughput controlled by:
- Brave result count (`--count`),
- concurrent fetch/extract workers (`--concurrency`).
Tuning guidance:
- start conservative (`--concurrency 2`, `--rps 10`) and increase gradually,
- use lower concurrency on unstable networks,
- avoid high parallelism if extractor host is resource-constrained.
### Reliability
- resumable runs via `scrape_runs` and `scrape_progress`,
- no socket-path collision due to per-process unique socket names,
- pooled socket self-healing on I/O error,
- explicit process-group shutdown for extractor.
### Failure Modes and Recovery
| Symptom | Likely Cause | Recovery |
|---|---|---|
| `failed to search` in `news` | missing/invalid `BRAVE_API_KEY` or API/network issue | check `.env`, retry with smaller `--count` |
| extractor startup timeout | Python env/deps not ready | run `uv sync`, retry command |
| `failed to open database` | invalid DB path/permissions | use writable path under `output/` |
| high fail count in `fetch-all` | API throttling/network instability | reduce `--concurrency`, reduce `--rps`, increase retries |
## Technical Implementation Notes
### Internal command model
- `cmd/rubick/main.go` delegates to `internal/cli.Run(args)`.
- each top-level command has dedicated handler logic.
- help-path exit code is `0`; invalid usage and command errors return non-zero.
### Data model and storage
SQLite tables persist both point-in-time and historical views. Export scripts consume the same DB, which makes the workflow reproducible and scriptable.
### Security and secret handling
- never hardcode API keys,
- keep `.env` out of source control,
- rotate `BRAVE_API_KEY` periodically,
- prefer environment injection in CI/CD rather than plaintext files.
## Extending the Codebase
### Add a new data source command
1. Add a new handler in `internal/cli`.
2. Register command routing in `Run(args)`.
3. Keep source-specific logic in a dedicated package (similar to `msn/`).
4. Add CLI regression tests in `tests/go`.
5. Add docs + deterministic sample in README.
### Add a new Python-assisted feature
1. Put Python implementation in `scripts/` or standalone server file.
2. Keep wire contract small and explicit if IPC is needed.
3. Add input/output schema tests in `tests/`.
4. Expose feature through one unified CLI command, not ad-hoc scripts.
### Go-only vs Python-only decisions
Use Go when:
- you need high-concurrency network orchestration,
- strong type-safety and binary distribution matter.
Use Python when:
- library ecosystem is materially better for the task,
- rapid iteration of parsing/formatting logic is needed.
Hybrid rule:
- keep orchestration in Go,
- isolate Python to specialized components with strict IPC contracts,
- document protocol and lifecycle clearly.
## Example Production-Like Workflow
```bash
TS=$(date +%Y%m%d-%H%M%S)
BASE=output/$TS
mkdir -p $BASE
# Collect core market dataset
./bin/rubick msn fetch-all --index idx80 --db $BASE/stocks.db --rps 15 --delay 150-400 --concurrency 4
# Enrich with news for macro keyword
./bin/rubick news "IHSG OR Jakarta Composite Index" --from 2026-03-01 --to 2026-03-06 --count 30 --concurrency 6 --output $BASE/news_macro.json
# Export for analyst consumption
./bin/rubick export dashboard --db $BASE/stocks.db --output $BASE/dashboard.xlsx
./bin/rubick export history --db $BASE/stocks.db --output $BASE/history.xlsx
./bin/rubick export simple --db $BASE/stocks.db --format csv --output $BASE/csv
```
## Glossary
| Term | Meaning |
|---|---|
| MSN ID | internal identifier used by MSN endpoints |
| Screener | preset query to select stocks by criteria |
| Fetch-all run | bulk ingestion execution tracked in DB |
| Extractor | Python process that converts raw HTML to article text |
| Stock mode query | boolean query generated from comma-separated stock terms |
## License / Internal Policy
Add your repository license and internal data-usage policy here if this project is used in production or shared environments.

87
TEST_REPORT.md Normal file
View file

@ -0,0 +1,87 @@
# Unified CLI Test Report
Date: 2026-03-06
## Summary
- Unified CLI commands tested: `msn`, `news`, `export`, `extractor`
- Parser/validation tested with valid and invalid arguments
- Error handling standardized: command modules return errors; root handles exit code
- Remaining hard exit: only top-level `main()` calls `os.Exit(runRoot(...))`
## Command Matrix (Representative)
### Root
- `go run .` -> usage printed, exit `1`
- `go run . --help` -> usage printed, exit `1`
- `go run . unknown` -> usage + `unknown command`, exit `1`
### MSN
- `go run . msn --help` -> MSN usage, exit `1`
- `go run . msn badcmd` -> error unknown subcommand, exit `1`
- `go run . msn lookup BBCA TLKM XXXX` -> success, resolves BBCA/TLKM, unknown marked not found, exit `0`
#### `msn screener`
- `--help` -> screener usage, exit `0`
- `--region` (missing value) -> error, exit `1`
- `--limit nope` -> parse error, exit `1`
- `--filter invalid` -> validation error, exit `1`
- valid invocation attempted -> DNS failure to `assets.msn.com` in this environment, exit `1`
#### `msn fetch`
- `--help` -> fetch usage, exit `0`
- no id source -> validation error, exit `1`
- `--input` missing value -> error, exit `1`
- `--tickers` missing value -> error, exit `1`
- `--concurrency nope` -> parse error, exit `1`
- `--input` missing file -> FS error, exit `1`
- valid with tickers -> success, output `/tmp/fetch_tickers.json`, exit `0`
- valid with ids -> success, output `/tmp/fetch_ids.json`, exit `0`
#### `msn fetch-all`
- `--help` -> fetch-all usage, exit `0`
- `--db` missing value -> error, exit `1`
- `--delay oops` -> format error, exit `1`
- `--rps nope` -> parse error, exit `1`
- valid `--index weird --limit 1` -> fallback to all, success, `/tmp/fetchall_weird.db`, exit `0`
- valid `--index idx30 --limit 1` -> success, `/tmp/fetchall_idx30.db`, exit `0`
### News
- `go run . news --help` -> news usage, exit `1`
- valid news request attempted -> fails with missing `BRAVE_API_KEY` in current shell env, exit `1`
- `--from bad-date` -> parse error, exit `1`
- `--to bad-date` -> parse error, exit `1`
- `--count nope` -> parse error, exit `1`
- `--concurrency nope` -> parse error, exit `1`
- `--output` missing value -> error, exit `1`
### Export
- `go run . export --help` -> usage, exit `1`
- `go run . export badtarget` -> validation error, exit `1`
- `go run . export dashboard --db /tmp/fetchall_idx30.db --output /tmp/dashboard_refactor.xlsx` -> success, exit `0`
- `go run . export history --db /tmp/fetchall_idx30.db --output /tmp/history_refactor.xlsx` -> success, exit `0`
### Extractor
- `go run . extractor` -> usage, exit `1`
- `go run . extractor --help` -> usage, exit `1`
## Produced Artifacts
- `/tmp/fetch_one.json`
- `/tmp/fetch_tickers.json`
- `/tmp/fetch_ids.json`
- `/tmp/fetchall_weird.db`
- `/tmp/fetchall_idx30.db`
- `/tmp/dashboard_refactor.xlsx`
- `/tmp/history_refactor.xlsx`
## Notes
- Network/API behavior is environment-dependent (DNS and API key availability).
- `news` command requires `BRAVE_API_KEY` in the running shell environment.
- CLI behavior now consistently reports parse/validation/runtime errors without deep `log.Fatal` exits.

165
extractor.py Normal file
View file

@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
Article text extractor using newspaper4k.
Runs as an asyncio Unix socket server for IPC with Go.
"""
import asyncio
import argparse
import json
import signal
import struct
import sys
import warnings
from pathlib import Path
from newspaper import Article
# Suppress asyncio warnings on forced shutdown
warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*coroutine.*")
# Protocol: 4-byte length prefix (big-endian uint32) + JSON payload
HEADER_SIZE = 4
async def extract_text(url: str, html: str) -> dict:
"""Extract article text from HTML using newspaper4k."""
try:
article = Article(url)
article.download(input_html=html)
article.parse()
text = article.text.strip()
if not text:
return {"text": "", "status": "failed", "error": "empty_text"}
return {"text": text, "status": "ok"}
except Exception as e:
return {"text": "", "status": "failed", "error": str(e)}
async def read_message(reader: asyncio.StreamReader) -> dict | None:
"""Read a length-prefixed JSON message."""
header = await reader.readexactly(HEADER_SIZE)
if not header:
return None
length = struct.unpack(">I", header)[0]
data = await reader.readexactly(length)
return json.loads(data.decode("utf-8"))
async def write_message(writer: asyncio.StreamWriter, msg: dict) -> None:
"""Write a length-prefixed JSON message."""
data = json.dumps(msg).encode("utf-8")
header = struct.pack(">I", len(data))
writer.write(header + data)
await writer.drain()
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
"""Handle a single client connection."""
try:
while True:
try:
msg = await read_message(reader)
except asyncio.IncompleteReadError:
break
except Exception:
break
if msg is None:
break
# Check for shutdown command
if msg.get("command") == "shutdown":
await write_message(writer, {"status": "ok", "message": "shutting_down"})
break
# Extract text
url = msg.get("url", "")
html = msg.get("html", "")
result = await extract_text(url, html)
await write_message(writer, result)
except Exception:
pass # Silently handle errors on shutdown
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def run_server(socket_path: str):
"""Run the Unix socket server."""
# Remove existing socket file if present
socket_file = Path(socket_path)
if socket_file.exists():
socket_file.unlink()
server = await asyncio.start_unix_server(handle_client, path=socket_path)
# Signal readiness to parent process
print(f"READY:{socket_path}", flush=True)
async with server:
try:
await server.serve_forever()
except asyncio.CancelledError:
pass
except Exception:
pass
# Cleanup socket file
if socket_file.exists():
socket_file.unlink()
def main():
# Suppress task destroyed warnings
import logging
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
parser = argparse.ArgumentParser(description="Article text extractor server")
parser.add_argument("--socket", required=True, help="Unix socket path")
args = parser.parse_args()
socket_file = Path(args.socket)
def cleanup():
if socket_file.exists():
socket_file.unlink()
# Handle signals for graceful shutdown
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, loop.stop)
try:
loop.run_until_complete(run_server(args.socket))
except (KeyboardInterrupt, RuntimeError):
pass
finally:
# Cancel all pending tasks
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Run until all tasks are cancelled
if pending:
try:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except RuntimeError:
# Event loop can be stopped by signal handlers during shutdown.
pass
loop.close()
cleanup()
if __name__ == "__main__":
main()

27
go.mod Normal file
View file

@ -0,0 +1,27 @@
module rubick
go 1.25.5
require (
github.com/enetx/g v1.0.210
github.com/enetx/surf v1.0.187
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.34
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/enetx/http v1.0.25 // indirect
github.com/enetx/http2 v1.0.25 // indirect
github.com/enetx/http3 v1.0.7 // indirect
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f // indirect
github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/wzshiming/socks5 v0.7.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

48
go.sum Normal file
View file

@ -0,0 +1,48 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/enetx/g v1.0.210 h1:V5Z9iAUwgW5Ou1hOHG2Fl/0EkuUnGIthj0U4wLLTOA4=
github.com/enetx/g v1.0.210/go.mod h1:6/HQeRy+tIJVGY+oRPQVJ/vOSruAi0aldFggurT6jBY=
github.com/enetx/http v1.0.25 h1:WE1+KEnjXIHP+hxbnTmAZ0p87UEmiHaE4CAQDLzL5C4=
github.com/enetx/http v1.0.25/go.mod h1:1f4mytfF/SfjATEJnynpwGS6aa1ALjb8DtmYgFVblY0=
github.com/enetx/http2 v1.0.25 h1:PSZN0I7j6Rzo3+rA6UE08xZITitBFSS7miSTBxedKFo=
github.com/enetx/http2 v1.0.25/go.mod h1:t54ex5HIS8V1+2j6cvEOv6umlrHsbUPFKQ54nYB58Nk=
github.com/enetx/http3 v1.0.7 h1:daFhveKBtv8rRallCjaHErzzSHIrq07ovoSvVkvhcMM=
github.com/enetx/http3 v1.0.7/go.mod h1:sqpVGZ9F1/wCiW6sjBUS2errKAh3SUYn6VlWE7LL6KM=
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f h1:GUW+4AWfECIEJ9oAxgEAVGCpaozMCjRiUYnuR6Q0bCQ=
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f/go.mod h1:oMZN8hGLUpi7QBlMEUqailocNy0NFAO/7Lu+Nwh9HMM=
github.com/enetx/surf v1.0.187 h1:EQj9jj7RFXXYyicrNW3D77XAWyl8Y8vh4mPmHy8+L6I=
github.com/enetx/surf v1.0.187/go.mod h1:nn2yuyWDc7r1qcJ68C4wtebN8v+KJdH8DINFcjOWtZE=
github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8 h1:jN2LdG4CG7cXOMAkQVDencj67Gdt7FFwDP1UaPcYyV8=
github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8/go.mod h1:jsHaW4RX6DteSbAHT/pW7iJxv7YXLf2NTr3k9PsmXoc=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wzshiming/socks5 v0.7.0 h1:euJ+U48WrvVngi+opC8vAnpZ5sK12y1C2hPvb1f48Rg=
github.com/wzshiming/socks5 v0.7.0/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

129
internal/cli/brave.go Normal file
View file

@ -0,0 +1,129 @@
package cli
import (
"encoding/json"
"fmt"
"net/url"
"os"
"time"
"github.com/enetx/g"
"github.com/enetx/surf"
)
// BraveResult represents a single news result from Brave Search API
type BraveResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
PageAge string `json:"page_age"`
}
// BraveNewsResponse represents the Brave News Search API response
type BraveNewsResponse struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
Age string `json:"age"`
} `json:"results"`
}
// SearchConfig holds search parameters
type SearchConfig struct {
Query string
From time.Time
To time.Time
Count int
}
// SearchBrave queries the Brave News Search API
// Uses dedicated News endpoint: GET /res/v1/news/search
func SearchBrave(client *surf.Client, config SearchConfig) ([]BraveResult, error) {
apiKey := os.Getenv("BRAVE_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("BRAVE_API_KEY environment variable not set")
}
// Build query parameters
params := url.Values{}
params.Set("q", config.Query)
params.Set("count", fmt.Sprintf("%d", config.Count))
params.Set("freshness", fmt.Sprintf("%sto%s",
config.From.Format("2006-01-02"),
config.To.Format("2006-01-02"),
))
apiURL := fmt.Sprintf("https://api.search.brave.com/res/v1/news/search?%s", params.Encode())
// Use plain client for API calls (no Chrome impersonation which overrides headers)
apiClient := surf.NewClient()
defer apiClient.CloseIdleConnections()
resp := apiClient.Get(g.String(apiURL)).
SetHeaders("Accept", "application/json").
SetHeaders("X-Subscription-Token", apiKey).
// Jakarta, Indonesia location headers
SetHeaders("X-Loc-Lat", "-6.2088").
SetHeaders("X-Loc-Long", "106.8456").
SetHeaders("X-Loc-Timezone", "Asia/Jakarta").
SetHeaders("X-Loc-Country", "ID").
Do()
if resp.IsErr() {
return nil, fmt.Errorf("brave API request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
body := r.Body.String().Ok().Std()
return nil, fmt.Errorf("brave API returned status %d: %s", r.StatusCode, body)
}
body := r.Body.String().Ok().Std()
var braveResp BraveNewsResponse
if err := json.Unmarshal([]byte(body), &braveResp); err != nil {
return nil, fmt.Errorf("failed to parse brave API response: %w", err)
}
if len(braveResp.Results) == 0 {
return []BraveResult{}, nil
}
results := make([]BraveResult, len(braveResp.Results))
for i, r := range braveResp.Results {
results[i] = BraveResult{
Title: r.Title,
URL: r.URL,
Description: r.Description,
PageAge: r.Age,
}
}
return results, nil
}
// BuildStockQuery creates a boolean query for Indonesian stock news
// Example: BuildStockQuery("MINA", "MINA Tbk") returns:
// ("MINA" OR "MINA Tbk") AND (saham OR emiten OR "Bursa Efek Indonesia" OR BEI OR IDX)
func BuildStockQuery(stockTerms ...string) string {
if len(stockTerms) == 0 {
return ""
}
// Build stock terms part
stockPart := "("
for i, term := range stockTerms {
if i > 0 {
stockPart += " OR "
}
stockPart += fmt.Sprintf(`"%s"`, term)
}
stockPart += ")"
// Indonesian stock market keywords
marketKeywords := `(saham OR emiten OR "Bursa Efek Indonesia" OR BEI OR IDX)`
return stockPart + " AND " + marketKeywords
}

282
internal/cli/extractor.go Normal file
View file

@ -0,0 +1,282 @@
package cli
import (
"bufio"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
// ExtractRequest is the request sent to Python extractor
type ExtractRequest struct {
URL string `json:"url"`
HTML string `json:"html"`
}
// ExtractResponse is the response from Python extractor
type ExtractResponse struct {
Text string `json:"text"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// Extractor manages the Python extraction process and socket communication
type Extractor struct {
socketPath string
cmd *exec.Cmd
mu sync.Mutex
connPool chan net.Conn
poolSize int
closed bool
}
// NewExtractor creates and starts the Python extractor process
func NewExtractor(poolSize int) (*Extractor, error) {
if poolSize < 1 {
poolSize = 1
}
socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("stock-news-extractor-%d-%d.sock", os.Getpid(), time.Now().UnixNano()))
e := &Extractor{
socketPath: socketPath,
poolSize: poolSize,
connPool: make(chan net.Conn, poolSize),
}
if err := e.start(); err != nil {
return nil, err
}
return e, nil
}
// start launches the Python extractor process
func (e *Extractor) start() error {
// Remove existing socket file if present
os.Remove(e.socketPath)
e.cmd = exec.Command("uv", "run", "python", "extractor.py", "--socket", e.socketPath)
e.cmd.Stderr = os.Stderr
// Create a new process group so we can kill all child processes
e.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := e.cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
if err := e.cmd.Start(); err != nil {
return fmt.Errorf("failed to start python extractor: %w", err)
}
// Wait for ready signal from Python
scanner := bufio.NewScanner(stdout)
ready := make(chan bool, 1)
go func() {
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "READY:") {
ready <- true
return
}
}
ready <- false
}()
select {
case ok := <-ready:
if !ok {
e.killProcessGroup()
return fmt.Errorf("python extractor failed to start")
}
case <-time.After(30 * time.Second):
e.killProcessGroup()
return fmt.Errorf("timeout waiting for python extractor to start")
}
// Initialize connection pool
for i := 0; i < e.poolSize; i++ {
conn, err := net.Dial("unix", e.socketPath)
if err != nil {
e.Close()
return fmt.Errorf("failed to connect to extractor: %w", err)
}
e.connPool <- conn
}
return nil
}
// killProcessGroup kills the entire process group
func (e *Extractor) killProcessGroup() {
if e.cmd != nil && e.cmd.Process != nil {
// Kill the entire process group (negative PID)
pgid, err := syscall.Getpgid(e.cmd.Process.Pid)
if err == nil {
syscall.Kill(-pgid, syscall.SIGTERM)
time.Sleep(100 * time.Millisecond)
syscall.Kill(-pgid, syscall.SIGKILL)
}
e.cmd.Process.Kill()
e.cmd.Wait()
}
}
// Extract sends HTML to Python and returns extracted text
func (e *Extractor) Extract(ctx context.Context, url, html string) (*ExtractResponse, error) {
// Get connection from pool
var conn net.Conn
select {
case conn = <-e.connPool:
case <-ctx.Done():
return nil, ctx.Err()
}
healthy := true
// Return connection to pool when done
defer func() {
if conn == nil {
return
}
if !e.closed && healthy {
e.connPool <- conn
return
}
_ = conn.Close()
if e.closed {
return
}
replacement, err := net.Dial("unix", e.socketPath)
if err != nil {
return
}
select {
case e.connPool <- replacement:
default:
_ = replacement.Close()
}
}()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(60 * time.Second))
}
defer conn.SetDeadline(time.Time{})
req := ExtractRequest{URL: url, HTML: html}
if err := writeMessage(conn, req); err != nil {
healthy = false
return nil, fmt.Errorf("failed to send request: %w", err)
}
var resp ExtractResponse
if err := readMessage(conn, &resp); err != nil {
healthy = false
return nil, fmt.Errorf("failed to read response: %w", err)
}
return &resp, nil
}
// Close shuts down the Python extractor
func (e *Extractor) Close() error {
e.mu.Lock()
if e.closed {
e.mu.Unlock()
return nil
}
e.closed = true
e.mu.Unlock()
// Drain and close all connections in pool
done := make(chan struct{})
go func() {
for i := 0; i < e.poolSize; i++ {
select {
case conn := <-e.connPool:
conn.Close()
case <-time.After(time.Second):
// Timeout waiting for connection
}
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
// Timeout waiting for pool drain
}
// Kill the process group
e.killProcessGroup()
// Clean up socket file
_ = os.Remove(e.socketPath)
return nil
}
// writeMessage writes a length-prefixed JSON message
func writeMessage(conn net.Conn, msg interface{}) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
// Write 4-byte length prefix (big-endian)
header := make([]byte, 4)
binary.BigEndian.PutUint32(header, uint32(len(data)))
if err := writeAll(conn, header); err != nil {
return err
}
if err := writeAll(conn, data); err != nil {
return err
}
return nil
}
// readMessage reads a length-prefixed JSON message
func readMessage(conn net.Conn, v interface{}) error {
// Read 4-byte length prefix
header := make([]byte, 4)
if _, err := io.ReadFull(conn, header); err != nil {
return err
}
length := binary.BigEndian.Uint32(header)
// Read JSON payload
data := make([]byte, length)
if _, err := io.ReadFull(conn, data); err != nil {
return err
}
return json.Unmarshal(data, v)
}
func writeAll(conn net.Conn, p []byte) error {
for len(p) > 0 {
n, err := conn.Write(p)
if err != nil {
return err
}
p = p[n:]
}
return nil
}

143
internal/cli/main.go Normal file
View file

@ -0,0 +1,143 @@
package cli
import (
"errors"
"fmt"
"os"
"os/exec"
)
func Run(args []string) int {
if len(args) == 0 {
printRootUsage()
return 1
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
printRootUsage()
return 0
}
switch args[0] {
case "msn":
return runMSNCommand(args[1:])
case "news":
return runNewsCommand(args[1:])
case "export":
return runExportCommand(args[1:])
case "extractor":
return runExtractorCommand(args[1:])
default:
printRootUsage()
fmt.Fprintf(os.Stderr, "\nerror: unknown command: %s\n", args[0])
return 1
}
}
func printRootUsage() {
fmt.Fprintf(os.Stderr, `Rubick - Unified Market Intelligence CLI
Usage:
rubick <command> [options]
Commands:
msn MSN finance workflows (screener, fetch, fetch-all, lookup)
news Brave news search + Python text extraction
export Python export tools (dashboard/history/simple)
extractor Run raw Python extractor server (advanced)
Examples:
# News mode with explicit command
rubick news "BBCA,Bank Central Asia" --stock --count 20
# MSN screener
rubick msn screener --region id --filter top-performers --limit 20 -o output/screener.json
# MSN fetch-all to SQLite
rubick msn fetch-all --index idx30 --db output/stocks.db --concurrency 3
# Export dashboard from SQLite
rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
# Export history workbook from SQLite
rubick export history --db output/stocks.db --output output/history.xlsx
# Export simple tables (json/csv/xlsx) from SQLite
rubick export simple --db output/stocks.db --format csv --output output/simple_csv
# Run extractor server directly
rubick extractor --socket /tmp/extractor.sock
`)
}
func runExportCommand(args []string) int {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, `Usage: rubick export <dashboard|history|simple> [script options]
Examples:
rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
rubick export history --db output/stocks.db --output output/history.xlsx
rubick export simple --db output/stocks.db --format csv --output output/csv/
`)
return 1
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Fprintf(os.Stderr, `Usage: rubick export <dashboard|history|simple> [script options]
Examples:
rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
rubick export history --db output/stocks.db --output output/history.xlsx
rubick export simple --db output/stocks.db --format csv --output output/csv/
`)
return 0
}
script := ""
switch args[0] {
case "dashboard":
script = "scripts/export_dashboard.py"
case "history":
script = "scripts/export_history.py"
case "simple":
script = "scripts/export_simple.py"
default:
fmt.Fprintf(os.Stderr, "Unknown export target: %s\n", args[0])
return 1
}
cmdArgs := append([]string{"run", "python", script}, args[1:]...)
return runPassthrough("uv", cmdArgs)
}
func runExtractorCommand(args []string) int {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Usage: rubick extractor --socket /tmp/extractor.sock\n")
return 1
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Fprintf(os.Stderr, "Usage: rubick extractor --socket /tmp/extractor.sock\n")
return 0
}
cmdArgs := append([]string{"run", "python", "extractor.py"}, args...)
return runPassthrough("uv", cmdArgs)
}
func runPassthrough(bin string, args []string) int {
cmd := exec.Command(bin, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
fmt.Fprintf(os.Stderr, "failed to run %s: %v\n", bin, err)
return 1
}
return 0
}

904
internal/cli/msn_cli.go Normal file
View file

@ -0,0 +1,904 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"rubick/msn"
)
func runMSNCommand(args []string) int {
if len(args) == 0 {
printMSNUsage()
return 1
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
printMSNUsage()
return 0
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
go func() {
<-sigChan
log.Println("Shutting down...")
cancel()
}()
if err := executeMSNCommand(ctx, args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
return 0
}
func executeMSNCommand(ctx context.Context, args []string) error {
subcommand := args[0]
subArgs := args[1:]
switch subcommand {
case "screener":
return runScreener(ctx, subArgs)
case "fetch":
return runFetch(ctx, subArgs)
case "fetch-all":
return runFetchAll(ctx, subArgs)
case "lookup":
return runLookup(subArgs)
default:
printMSNUsage()
return fmt.Errorf("unknown msn subcommand: %s", subcommand)
}
}
func printMSNUsage() {
fmt.Fprintf(os.Stderr, `MSN Stock Scraper - Fetch Indonesian stock data from MSN Finance
Usage:
rubick msn <command> [options]
Commands:
screener Run stock screener to find stocks by criteria
fetch Fetch comprehensive data for specific stocks
fetch-all Fetch ALL Indonesian stocks to SQLite database
lookup Look up MSN ID for ticker symbols
Screener:
rubick msn screener --region id --filter top-performers --limit 20 -o stocks.json
Fetch:
rubick msn fetch --tickers BBCA,BBRI,TLKM -o bank_stocks.json
rubick msn fetch --input stocks.json -o full_data.json
Fetch-All:
rubick msn fetch-all --index idx30 --db output/stocks.db --concurrency 3
Lookup:
rubick msn lookup BBCA BBRI TLKM
`)
}
// Screener
type ScreenerCLIConfig struct {
Region string
Filter string
Limit int
Output string
}
func parseScreenerArgs(args []string) (ScreenerCLIConfig, error) {
cfg := ScreenerCLIConfig{
Region: "id",
Filter: "large-cap",
Limit: 50,
Output: fmt.Sprintf("screener_%s.json", time.Now().Format("20060102")),
}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--region":
v, n, err := requireValue(args, i, "--region")
if err != nil {
return cfg, err
}
cfg.Region = v
i = n
case "--filter":
v, n, err := requireValue(args, i, "--filter")
if err != nil {
return cfg, err
}
cfg.Filter = v
i = n
case "--limit":
v, n, err := requireValue(args, i, "--limit")
if err != nil {
return cfg, err
}
limit, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("invalid --limit value: %w", err)
}
cfg.Limit = limit
i = n
case "--output", "-o":
v, n, err := requireValue(args, i, "--output")
if err != nil {
return cfg, err
}
cfg.Output = v
i = n
default:
return cfg, fmt.Errorf("unknown option: %s", args[i])
}
}
if cfg.Limit < 1 {
return cfg, fmt.Errorf("--limit must be >= 1")
}
return cfg, nil
}
func runScreener(ctx context.Context, args []string) error {
if wantsHelp(args) {
printMSNScreenerUsage()
return nil
}
cfg, err := parseScreenerArgs(args)
if err != nil {
return err
}
filter, err := msn.ParseScreenerFilter(cfg.Filter)
if err != nil {
return fmt.Errorf("invalid filter: %w", err)
}
log.Printf("Running screener: region=%s filter=%s limit=%d", cfg.Region, cfg.Filter, cfg.Limit)
client := msn.NewMSNClient()
defer client.Close()
result, err := client.RunScreener(msn.ScreenerConfig{Region: cfg.Region, Filter: filter, Limit: cfg.Limit})
if err != nil {
return fmt.Errorf("screener failed: %w", err)
}
output := msn.ScreenerOutput{
Filter: cfg.Filter,
Region: cfg.Region,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Total: result.Total,
Stocks: result.Value,
}
if err := saveJSON(output, cfg.Output); err != nil {
return fmt.Errorf("failed to save output: %w", err)
}
log.Printf("Output saved to %s", cfg.Output)
fmt.Println("\nTop 10 results:")
for i, stock := range result.Value {
if i >= 10 {
break
}
fmt.Printf(" %s (%s): %.2f (%.2f%%)\n", stock.Symbol, stock.ID, stock.Price, stock.PriceChangePct)
}
return nil
}
// Fetch
type FetchCLIConfig struct {
Input string
IDs []string
Tickers []string
Concurrency int
Output string
}
func parseFetchArgs(args []string) (FetchCLIConfig, error) {
cfg := FetchCLIConfig{Concurrency: 5, Output: fmt.Sprintf("stocks_%s.json", time.Now().Format("20060102"))}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--input":
v, n, err := requireValue(args, i, "--input")
if err != nil {
return cfg, err
}
cfg.Input = v
i = n
case "--ids":
v, n, err := requireValue(args, i, "--ids")
if err != nil {
return cfg, err
}
cfg.IDs = appendCSV(cfg.IDs, v, false)
i = n
case "--tickers":
v, n, err := requireValue(args, i, "--tickers")
if err != nil {
return cfg, err
}
cfg.Tickers = appendCSV(cfg.Tickers, v, true)
i = n
case "--concurrency":
v, n, err := requireValue(args, i, "--concurrency")
if err != nil {
return cfg, err
}
conc, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("invalid --concurrency value: %w", err)
}
cfg.Concurrency = conc
i = n
case "--output", "-o":
v, n, err := requireValue(args, i, "--output")
if err != nil {
return cfg, err
}
cfg.Output = v
i = n
default:
return cfg, fmt.Errorf("unknown option: %s", args[i])
}
}
if cfg.Concurrency < 1 {
return cfg, fmt.Errorf("--concurrency must be >= 1")
}
return cfg, nil
}
func runFetch(ctx context.Context, args []string) error {
if wantsHelp(args) {
printMSNFetchUsage()
return nil
}
cfg, err := parseFetchArgs(args)
if err != nil {
return err
}
ids := make([]string, 0)
if cfg.Input != "" {
inputIDs, err := readScreenerOutput(cfg.Input)
if err != nil {
return fmt.Errorf("failed to read input file: %w", err)
}
ids = append(ids, inputIDs...)
}
ids = append(ids, cfg.IDs...)
for _, ticker := range cfg.Tickers {
id := msn.GetIDXStockID(ticker)
if id == "" {
log.Printf("Warning: Unknown ticker '%s', skipping", ticker)
continue
}
log.Printf("Resolved %s -> %s", ticker, id)
ids = append(ids, id)
}
if len(ids) == 0 {
return fmt.Errorf("no stock IDs provided. use --input, --ids, or --tickers")
}
ids = dedupe(ids)
log.Printf("Fetching data for %d stocks with concurrency %d", len(ids), cfg.Concurrency)
fetcher := msn.NewStockFetcher()
defer fetcher.Close()
stocks := fetcher.FetchStocks(ctx, ids, cfg.Concurrency)
output := msn.FetchOutput{GeneratedAt: time.Now().UTC().Format(time.RFC3339), Total: len(stocks), Stocks: stocks}
if err := saveJSON(output, cfg.Output); err != nil {
return fmt.Errorf("failed to save output: %w", err)
}
successCount := 0
for _, stock := range stocks {
apiSuccess := 0
for _, status := range stock.FetchStatus {
if status == "ok" {
apiSuccess++
}
}
if apiSuccess > 0 {
successCount++
}
}
log.Printf("Output saved to %s", cfg.Output)
log.Printf("Successfully fetched %d/%d stocks", successCount, len(stocks))
return nil
}
func readScreenerOutput(filename string) ([]string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
var output msn.ScreenerOutput
if err := json.Unmarshal(data, &output); err != nil {
return nil, err
}
ids := make([]string, len(output.Stocks))
for i, stock := range output.Stocks {
ids[i] = stock.ID
}
return ids, nil
}
func saveJSON(data any, filename string) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(filename); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return os.WriteFile(filename, jsonData, 0o644)
}
func runLookup(args []string) error {
if wantsHelp(args) {
printMSNLookupUsage()
return nil
}
if len(args) == 0 {
return fmt.Errorf("usage: rubick msn lookup <ticker1> [ticker2] ...")
}
fmt.Printf("%-8s %-10s %s\n", "Ticker", "MSN ID", "Company Name")
fmt.Println(strings.Repeat("-", 50))
found := 0
for _, ticker := range args {
ticker = strings.ToUpper(strings.TrimSpace(ticker))
stock, ok := msn.GetIDXStock(ticker)
if ok {
fmt.Printf("%-8s %-10s %s\n", ticker, stock.ID, stock.Name)
found++
} else {
fmt.Printf("%-8s %-10s %s\n", ticker, "-", "(not found)")
}
}
fmt.Println(strings.Repeat("-", 50))
fmt.Printf("Found %d/%d tickers\n", found, len(args))
return nil
}
// Fetch-all
type FetchAllConfig struct {
DB string
Index string
Proxy string
Concurrency int
RPS float64
MinDelayMs int
MaxDelayMs int
Retry int
Limit int
Resume bool
}
func parseFetchAllArgs(args []string) (FetchAllConfig, error) {
cfg := FetchAllConfig{
DB: "output/stocks.db",
Index: "all",
Concurrency: 5,
RPS: 25,
MinDelayMs: 100,
MaxDelayMs: 500,
Retry: 2,
}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--db":
v, n, err := requireValue(args, i, "--db")
if err != nil {
return cfg, err
}
cfg.DB = v
i = n
case "--index":
v, n, err := requireValue(args, i, "--index")
if err != nil {
return cfg, err
}
cfg.Index = strings.ToLower(v)
i = n
case "--proxy":
v, n, err := requireValue(args, i, "--proxy")
if err != nil {
return cfg, err
}
cfg.Proxy = v
i = n
case "--concurrency":
v, n, err := requireValue(args, i, "--concurrency")
if err != nil {
return cfg, err
}
nval, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("invalid --concurrency value: %w", err)
}
cfg.Concurrency = nval
i = n
case "--rps":
v, n, err := requireValue(args, i, "--rps")
if err != nil {
return cfg, err
}
fval, err := strconv.ParseFloat(v, 64)
if err != nil {
return cfg, fmt.Errorf("invalid --rps value: %w", err)
}
cfg.RPS = fval
i = n
case "--delay":
v, n, err := requireValue(args, i, "--delay")
if err != nil {
return cfg, err
}
minDelay, maxDelay, err := parseDelay(v)
if err != nil {
return cfg, err
}
cfg.MinDelayMs, cfg.MaxDelayMs = minDelay, maxDelay
i = n
case "--retry":
v, n, err := requireValue(args, i, "--retry")
if err != nil {
return cfg, err
}
nval, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("invalid --retry value: %w", err)
}
cfg.Retry = nval
i = n
case "--limit":
v, n, err := requireValue(args, i, "--limit")
if err != nil {
return cfg, err
}
nval, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("invalid --limit value: %w", err)
}
cfg.Limit = nval
i = n
case "--resume":
cfg.Resume = true
default:
return cfg, fmt.Errorf("unknown option: %s", args[i])
}
}
if cfg.Concurrency < 1 {
return cfg, fmt.Errorf("--concurrency must be >= 1")
}
if cfg.RPS <= 0 {
return cfg, fmt.Errorf("--rps must be > 0")
}
if cfg.MinDelayMs < 0 || cfg.MaxDelayMs < 0 || cfg.MinDelayMs > cfg.MaxDelayMs {
return cfg, fmt.Errorf("--delay must satisfy 0 <= min <= max")
}
if cfg.Retry < 0 {
return cfg, fmt.Errorf("--retry must be >= 0")
}
if cfg.Limit < 0 {
return cfg, fmt.Errorf("--limit must be >= 0")
}
return cfg, nil
}
func runFetchAll(ctx context.Context, args []string) error {
if wantsHelp(args) {
printMSNFetchAllUsage()
return nil
}
cfg, err := parseFetchAllArgs(args)
if err != nil {
return err
}
log.Printf("Fetch-All Configuration:")
log.Printf(" Database: %s", cfg.DB)
log.Printf(" Index: %s", cfg.Index)
log.Printf(" Concurrency: %d workers", cfg.Concurrency)
log.Printf(" Rate limit: %.1f req/sec", cfg.RPS)
log.Printf(" Delay: %d-%d ms", cfg.MinDelayMs, cfg.MaxDelayMs)
log.Printf(" Retry: %d attempts", cfg.Retry)
if cfg.Proxy != "" {
log.Printf(" Proxy: %s", cfg.Proxy)
}
if cfg.Limit > 0 {
log.Printf(" Limit: %d stocks", cfg.Limit)
}
if cfg.Resume {
log.Printf(" Resume: enabled")
}
db, err := msn.NewStockDB(cfg.DB)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()
stocks := getStocksByIndex(cfg.Index)
if cfg.Limit > 0 && cfg.Limit < len(stocks) {
limited := make(map[string]msn.IDXStock)
count := 0
for ticker, stock := range stocks {
if count >= cfg.Limit {
break
}
limited[ticker] = stock
count++
}
stocks = limited
}
log.Printf("Stock list: %d stocks from '%s' index", len(stocks), cfg.Index)
var runID int64
if cfg.Resume {
runID, err = db.GetLastRunID()
if err != nil {
return fmt.Errorf("failed to get last run: %w", err)
}
if runID > 0 {
log.Printf("Resuming run #%d", runID)
} else {
log.Printf("No incomplete run found, starting fresh")
cfg.Resume = false
}
}
if !cfg.Resume {
cfgMap := map[string]any{
"index": cfg.Index,
"concurrency": cfg.Concurrency,
"rps": cfg.RPS,
"delay": fmt.Sprintf("%d-%d", cfg.MinDelayMs, cfg.MaxDelayMs),
"retry": cfg.Retry,
"proxy": cfg.Proxy != "",
}
runID, err = db.StartScrapeRun(len(stocks), cfgMap)
if err != nil {
return fmt.Errorf("failed to start run: %w", err)
}
log.Printf("Started run #%d", runID)
if err := db.InitProgress(runID, stocks); err != nil {
return fmt.Errorf("failed to init progress: %w", err)
}
}
pendingStocks, err := db.GetPendingStocks(runID)
if err != nil {
return fmt.Errorf("failed to get pending stocks: %w", err)
}
log.Printf("Pending: %d stocks to process", len(pendingStocks))
if len(pendingStocks) == 0 {
log.Println("No pending stocks, run complete")
return nil
}
rateLimiter := msn.NewRateLimiter(msn.RateLimiterConfig{RequestsPerSecond: cfg.RPS, MinDelayMs: cfg.MinDelayMs, MaxDelayMs: cfg.MaxDelayMs})
client := msn.NewMSNClientWithConfig(msn.MSNClientConfig{Proxy: cfg.Proxy, RateLimiter: rateLimiter})
defer client.Close()
type workItem struct{ ID, Ticker string }
workChan := make(chan workItem, len(pendingStocks))
for _, s := range pendingStocks {
workChan <- workItem{ID: s.ID, Ticker: s.Ticker}
}
close(workChan)
var processed, successful, failed int
total := len(pendingStocks)
startTime := time.Now()
statusTicker := time.NewTicker(5 * time.Second)
defer statusTicker.Stop()
go func() {
for range statusTicker.C {
elapsed := time.Since(startTime)
rate := float64(processed) / elapsed.Seconds()
remaining := total - processed
eta := time.Duration(float64(remaining)/rate) * time.Second
log.Printf("Progress: %d/%d (%.1f%%) | Success: %d | Failed: %d | Rate: %.1f/s | ETA: %s",
processed, total, float64(processed)*100/float64(total), successful, failed, rate, eta.Round(time.Second))
}
}()
done := make(chan bool)
results := make(chan struct {
ticker string
success bool
apis int
err string
}, cfg.Concurrency)
for w := 0; w < cfg.Concurrency; w++ {
go func() {
for {
select {
case <-ctx.Done():
return
case work, ok := <-workChan:
if !ok {
return
}
db.MarkProgressStarted(runID, work.ID)
var stockData *msn.StockData
var fetchErr error
for attempt := 0; attempt <= cfg.Retry; attempt++ {
stockData, fetchErr = client.FetchStockData(work.ID)
if fetchErr == nil {
break
}
if attempt < cfg.Retry {
time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond)
}
}
apisSuccess, apisFailed := 0, 0
if stockData != nil {
for _, status := range stockData.FetchStatus {
if status == "ok" {
apisSuccess++
} else {
apisFailed++
}
}
if err := db.SaveStockData(stockData); err != nil {
fetchErr = fmt.Errorf("save failed: %w", err)
}
}
status := "success"
errMsg := ""
if fetchErr != nil || apisSuccess == 0 {
status = "failed"
if fetchErr != nil {
errMsg = fetchErr.Error()
}
}
db.UpdateProgress(runID, work.ID, status, apisSuccess, apisFailed, errMsg)
results <- struct {
ticker string
success bool
apis int
err string
}{work.Ticker, status == "success", apisSuccess, errMsg}
}
}
}()
}
go func() {
for processed < total {
select {
case <-ctx.Done():
done <- false
return
case r := <-results:
processed++
if r.success {
successful++
log.Printf("[%d/%d] %s - %d APIs succeeded", processed, total, r.ticker, r.apis)
} else {
failed++
log.Printf("[%d/%d] %s - FAILED: %s", processed, total, r.ticker, r.err)
}
}
}
done <- true
}()
completed := <-done
elapsed := time.Since(startTime)
if completed {
db.CompleteScrapeRun(runID, "completed")
log.Printf("\n=== Run #%d Completed ===", runID)
} else {
db.CompleteScrapeRun(runID, "interrupted")
log.Printf("\n=== Run #%d Interrupted ===", runID)
}
log.Printf("Total: %d | Success: %d | Failed: %d", processed, successful, failed)
log.Printf("Duration: %s | Rate: %.1f stocks/sec", elapsed.Round(time.Second), float64(processed)/elapsed.Seconds())
log.Printf("Database: %s", cfg.DB)
staleStocks, _ := db.GetStaleStocks(7)
if len(staleStocks) > 0 {
log.Printf("\nWarning: %d stocks not seen in 7+ days:", len(staleStocks))
for i, ticker := range staleStocks {
if i >= 10 {
log.Printf(" ... and %d more", len(staleStocks)-10)
break
}
log.Printf(" - %s", ticker)
}
}
return nil
}
// Helpers
func requireValue(args []string, i int, flag string) (string, int, error) {
if i+1 >= len(args) {
return "", i, fmt.Errorf("%s requires a value", flag)
}
return args[i+1], i + 1, nil
}
func appendCSV(dst []string, csv string, upper bool) []string {
for _, part := range strings.Split(csv, ",") {
v := strings.TrimSpace(part)
if upper {
v = strings.ToUpper(v)
}
if v != "" {
dst = append(dst, v)
}
}
return dst
}
func dedupe(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func parseDelay(v string) (int, int, error) {
parts := strings.Split(v, "-")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid --delay value (use format min-max)")
}
minDelay, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid --delay min value: %w", err)
}
maxDelay, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid --delay max value: %w", err)
}
if minDelay > maxDelay {
return 0, 0, fmt.Errorf("invalid --delay value: min must be <= max")
}
return minDelay, maxDelay, nil
}
func wantsHelp(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
return true
}
}
return false
}
func printMSNScreenerUsage() {
fmt.Fprintf(os.Stderr, `Usage: rubick msn screener [options]
Options:
--region <code> Country code (default: id)
--filter <preset> top-performers|worst-performers|high-dividend|low-pe|52w-high|52w-low|high-volume|large-cap
--limit <n> Max results (default: 50)
--output, -o <file> Output JSON path
`)
}
func printMSNFetchUsage() {
fmt.Fprintf(os.Stderr, `Usage: rubick msn fetch [options]
Options:
--input <file> Screener JSON input
--ids <id1,id2,...> Comma-separated MSN IDs
--tickers <T1,T2,...> Comma-separated ticker symbols
--concurrency <n> Parallel workers (default: 5)
--output, -o <file> Output JSON path
`)
}
func printMSNFetchAllUsage() {
fmt.Fprintf(os.Stderr, `Usage: rubick msn fetch-all [options]
Options:
--db <file> SQLite database path (default: output/stocks.db)
--index <name> all|lq45|idx30|idx80 (default: all)
--proxy <url> Proxy URL (http://, https://, socks5://)
--concurrency <n> Parallel workers (default: 5)
--rps <n> Max requests/sec (default: 25)
--delay <min-max> Random delay ms (default: 100-500)
--retry <n> Retry attempts (default: 2)
--limit <n> Process only N stocks
--resume Resume incomplete run
`)
}
func printMSNLookupUsage() {
fmt.Fprintf(os.Stderr, `Usage: rubick msn lookup <ticker1> [ticker2] ...
`)
}
func getStocksByIndex(index string) map[string]msn.IDXStock {
allStocks := msn.GetAllIDXStocks()
switch index {
case "all":
return allStocks
case "lq45":
return filterStocks(allStocks, []string{
"ACES", "ADRO", "AKRA", "AMMN", "AMRT", "ANTM", "ASII", "BBCA",
"BBNI", "BBRI", "BBTN", "BMRI", "BRPT", "BUKA", "CPIN", "EMTK",
"ESSA", "EXCL", "GGRM", "GOTO", "HRUM", "ICBP", "INCO", "INDF",
"INKP", "INTP", "ITMG", "KLBF", "MAPI", "MBMA", "MDKA", "MEDC",
"PGAS", "PGEO", "PTBA", "SIDO", "SMGR", "TBIG", "TINS", "TLKM",
"TOWR", "UNTR", "UNVR", "WIKA",
})
case "idx30":
return filterStocks(allStocks, []string{
"ADRO", "AMRT", "ANTM", "ASII", "BBCA", "BBNI", "BBRI", "BMRI",
"BRPT", "CPIN", "EMTK", "EXCL", "GOTO", "ICBP", "INCO", "INDF",
"ITMG", "KLBF", "MDKA", "MEDC", "PGAS", "PTBA", "SMGR", "TBIG",
"TINS", "TLKM", "TOWR", "UNTR", "UNVR",
})
case "idx80":
return filterStocks(allStocks, []string{
"ACES", "ADRO", "AGII", "AKRA", "AMMN", "AMRT", "ANTM", "ARTO",
"ASII", "BBCA", "BBNI", "BBRI", "BBTN", "BFIN", "BMRI", "BRPT",
"BSDE", "BTPS", "BUKA", "CPIN", "CTRA", "DMAS", "EMTK", "ERAA",
"ESSA", "EXCL", "GGRM", "GOTO", "HEAL", "HMSP", "HRUM", "ICBP",
"INCO", "INDF", "INKP", "INTP", "ITMG", "JPFA", "JSMR", "KLBF",
"LPKR", "LPPF", "MAPI", "MBMA", "MDKA", "MEDC", "MIKA", "MNCN",
"PGAS", "PGEO", "PNBN", "PTBA", "PTPP", "PWON", "SCMA", "SIDO",
"SMGR", "SMRA", "SRTG", "TAPG", "TBIG", "TINS", "TKIM", "TLKM",
"TOWR", "TPIA", "UNTR", "UNVR", "WIKA", "WSKT",
})
default:
log.Printf("Unknown index '%s', using all stocks", index)
return allStocks
}
}
func filterStocks(all map[string]msn.IDXStock, tickers []string) map[string]msn.IDXStock {
result := make(map[string]msn.IDXStock)
for _, ticker := range tickers {
if stock, ok := all[ticker]; ok {
result[ticker] = stock
}
}
return result
}

336
internal/cli/news_cli.go Normal file
View file

@ -0,0 +1,336 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/enetx/g"
"github.com/enetx/surf"
"github.com/joho/godotenv"
)
type NewsConfig struct {
Query string
From time.Time
To time.Time
Count int
Concurrency int
Output string
StockMode bool
}
type EnrichedResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
PageAge string `json:"page_age"`
Text string `json:"text"`
FetchStatus string `json:"fetch_status"`
ExtractStatus string `json:"extract_status"`
}
type OutputData struct {
Query string `json:"query"`
GeneratedAt string `json:"generated_at"`
Results []EnrichedResult `json:"results"`
}
func runNewsCommand(args []string) int {
godotenv.Load()
if len(args) == 0 {
printNewsUsage()
return 1
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
printNewsUsage()
return 0
}
cfg, err := parseNewsArgs(args)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
if err := executeNews(cfg); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
return 0
}
func executeNews(config NewsConfig) error {
finalQuery := config.Query
if config.StockMode {
terms := strings.Split(config.Query, ",")
for i := range terms {
terms[i] = strings.TrimSpace(terms[i])
}
finalQuery = BuildStockQuery(terms...)
log.Printf("Stock mode query: %s", finalQuery)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
go func() {
<-sigChan
log.Println("Shutting down...")
cancel()
}()
client := surf.NewClient().Builder().Impersonate().Chrome().Build().Unwrap()
defer client.CloseIdleConnections()
log.Printf("Searching for: %s", finalQuery)
log.Printf("Date range: %s to %s, count: %d", config.From.Format("2006-01-02"), config.To.Format("2006-01-02"), config.Count)
searchResults, err := SearchBrave(client, SearchConfig{Query: finalQuery, From: config.From, To: config.To, Count: config.Count})
if err != nil {
return fmt.Errorf("failed to search: %w", err)
}
log.Printf("Found %d results", len(searchResults))
if len(searchResults) == 0 {
log.Println("No results found, exiting")
return nil
}
log.Println("Starting Python extractor...")
extractor, err := NewExtractor(config.Concurrency)
if err != nil {
return fmt.Errorf("failed to start extractor: %w", err)
}
defer extractor.Close()
log.Println("Python extractor ready")
results := processURLs(ctx, client, extractor, searchResults, config.Concurrency)
output := OutputData{Query: finalQuery, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Results: results}
if err := saveOutput(output, config.Output); err != nil {
return fmt.Errorf("failed to save output: %w", err)
}
successCount := 0
for _, r := range results {
if r.ExtractStatus == "ok" {
successCount++
}
}
log.Printf("Output saved to %s", config.Output)
log.Printf("Successfully extracted %d/%d articles", successCount, len(results))
return nil
}
func printNewsUsage() {
fmt.Fprintf(os.Stderr, `Usage: rubick news <query> [options]
Arguments:
<query> Search query (required)
For --stock mode: comma-separated stock terms
Options:
--from <date> Start date in YYYY-MM-DD format (default: 7 days ago)
--to <date> End date in YYYY-MM-DD format (default: today)
--count <n> Number of results to fetch (default: 20)
--concurrency <n> Number of parallel workers (default: 10)
--output, -o <file> Output file path (default: output_YYYYMMDD.json)
--stock Auto-builds IDX-focused boolean query
Environment:
BRAVE_API_KEY Brave Search API key
Examples:
rubick news "IHSG stock market"
rubick news "BBCA,Bank Central Asia" --stock --from 2026-02-01 --to 2026-02-10
`)
}
func parseNewsArgs(args []string) (NewsConfig, error) {
query := args[0]
args = args[1:]
now := time.Now()
cfg := NewsConfig{
Query: query,
From: now.AddDate(0, 0, -7),
To: now,
Count: 20,
Concurrency: 10,
Output: fmt.Sprintf("output_%s.json", now.Format("20060102")),
}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--from":
v, n, err := requireValue(args, i, "--from")
if err != nil {
return cfg, err
}
t, err := time.Parse("2006-01-02", v)
if err != nil {
return cfg, fmt.Errorf("invalid --from date: %w", err)
}
cfg.From = t
i = n
case "--to":
v, n, err := requireValue(args, i, "--to")
if err != nil {
return cfg, err
}
t, err := time.Parse("2006-01-02", v)
if err != nil {
return cfg, fmt.Errorf("invalid --to date: %w", err)
}
cfg.To = t
i = n
case "--count":
v, n, err := requireValue(args, i, "--count")
if err != nil {
return cfg, err
}
nval, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("invalid --count value: %w", err)
}
cfg.Count = nval
i = n
case "--concurrency":
v, n, err := requireValue(args, i, "--concurrency")
if err != nil {
return cfg, err
}
nval, err := strconv.Atoi(v)
if err != nil {
return cfg, fmt.Errorf("invalid --concurrency value: %w", err)
}
cfg.Concurrency = nval
i = n
case "--output", "-o":
v, n, err := requireValue(args, i, "--output")
if err != nil {
return cfg, err
}
cfg.Output = v
i = n
case "--stock":
cfg.StockMode = true
default:
return cfg, fmt.Errorf("unknown option: %s", args[i])
}
}
if cfg.Count < 1 {
return cfg, fmt.Errorf("--count must be >= 1")
}
if cfg.Concurrency < 1 {
return cfg, fmt.Errorf("--concurrency must be >= 1")
}
if cfg.From.After(cfg.To) {
return cfg, fmt.Errorf("--from must be on or before --to")
}
return cfg, nil
}
func processURLs(ctx context.Context, client *surf.Client, extractor *Extractor, searchResults []BraveResult, concurrency int) []EnrichedResult {
results := make([]EnrichedResult, len(searchResults))
for i, sr := range searchResults {
results[i] = EnrichedResult{Title: sr.Title, URL: sr.URL, Description: sr.Description, PageAge: sr.PageAge, FetchStatus: "pending", ExtractStatus: "pending"}
}
work := make(chan int, len(searchResults))
for i := range searchResults {
work <- i
}
close(work)
var wg sync.WaitGroup
var mu sync.Mutex
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case idx, ok := <-work:
if !ok {
return
}
result := processURL(ctx, client, extractor, searchResults[idx])
mu.Lock()
results[idx] = result
mu.Unlock()
log.Printf("[%d/%d] %s - fetch: %s, extract: %s", idx+1, len(searchResults), truncate(result.URL, 50), result.FetchStatus, result.ExtractStatus)
}
}
}()
}
wg.Wait()
return results
}
func processURL(ctx context.Context, client *surf.Client, extractor *Extractor, sr BraveResult) EnrichedResult {
result := EnrichedResult{Title: sr.Title, URL: sr.URL, Description: sr.Description, PageAge: sr.PageAge}
if err := ctx.Err(); err != nil {
result.FetchStatus = "cancelled"
result.ExtractStatus = "skipped"
return result
}
resp := client.Get(g.String(sr.URL)).Do()
if resp.IsErr() {
result.FetchStatus = "failed"
result.ExtractStatus = "skipped"
return result
}
r := resp.Ok()
if r.StatusCode != 200 {
result.FetchStatus = "failed"
result.ExtractStatus = "skipped"
return result
}
html := r.Body.String().Ok().Std()
result.FetchStatus = "ok"
extractResp, err := extractor.Extract(ctx, sr.URL, html)
if err != nil {
result.ExtractStatus = "failed"
return result
}
result.Text = extractResp.Text
result.ExtractStatus = extractResp.Status
return result
}
func saveOutput(output OutputData, filename string) error {
data, err := json.MarshalIndent(output, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(filename); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return os.WriteFile(filename, data, 0o644)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}

11
main.go Normal file
View file

@ -0,0 +1,11 @@
package main
import (
"os"
"rubick/internal/cli"
)
func main() {
os.Exit(cli.Run(os.Args[1:]))
}

324
msn/bing_client.go Normal file
View file

@ -0,0 +1,324 @@
package msn
import (
"encoding/json"
"fmt"
"github.com/enetx/g"
"github.com/enetx/surf"
)
// BingClient is the client for Bing Finance APIs (ownership data)
type BingClient struct {
client *surf.Client
}
// NewBingClient creates a new Bing API client with Chrome impersonation
func NewBingClient() *BingClient {
client := surf.NewClient().
Builder().
Impersonate().
Chrome().
Build().
Unwrap()
return &BingClient{client: client}
}
// Close closes idle connections
func (c *BingClient) Close() {
c.client.CloseIdleConnections()
}
// commonHeaders returns common headers for Bing API requests
func (c *BingClient) commonHeaders() map[string]string {
return map[string]string{
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://www.msn.com",
"Referer": "https://www.msn.com/",
}
}
// GetTopShareHolders fetches top institutional shareholders
func (c *BingClient) GetTopShareHolders(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopShareHolders/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("top shareholders request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("top shareholders API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse top shareholders response: %w", err)
}
// Return whichever field has data
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetTopBuyers fetches recent top buyers
func (c *BingClient) GetTopBuyers(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopBuyers/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("top buyers request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("top buyers API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse top buyers response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetTopSellers fetches recent top sellers
func (c *BingClient) GetTopSellers(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopSellers/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("top sellers request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("top sellers API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse top sellers response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetNewShareHolders fetches new institutional holders
func (c *BingClient) GetNewShareHolders(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopNewShareHolders/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("new shareholders request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("new shareholders API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse new shareholders response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// GetExitedShareHolders fetches exited institutional holders
func (c *BingClient) GetExitedShareHolders(id string, count int) ([]Holder, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
if count <= 0 {
count = 50
}
apiURL := fmt.Sprintf("%sGetSecurityTopExitedShareHolders/%s?rangeStart=1&count=%d",
BingAPIBaseURL,
id,
count,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("exited shareholders request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("exited shareholders API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result OwnershipResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse exited shareholders response: %w", err)
}
if len(result.SecurityOwnerships) > 0 {
return result.SecurityOwnerships, nil
}
return result.Records, nil
}
// IsInvestorDataAvailable checks if investor data exists for a stock
func (c *BingClient) IsInvestorDataAvailable(id string) (bool, error) {
if id == "" {
return false, fmt.Errorf("no stock ID provided")
}
apiURL := fmt.Sprintf("%sIsInvestorDataAvailable/%s",
BingAPIBaseURL,
id,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return false, fmt.Errorf("investor data check request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return false, fmt.Errorf("investor data check API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var available bool
if err := json.Unmarshal([]byte(body), &available); err != nil {
return false, fmt.Errorf("failed to parse investor data check response: %w", err)
}
return available, nil
}
// GetAllOwnership fetches all ownership data for a stock
func (c *BingClient) GetAllOwnership(id string, count int) (*OwnershipData, error) {
ownership := &OwnershipData{}
// Skip IsInvestorDataAvailable check as it often returns 404 even when data exists
// Just try to fetch the data directly
// Fetch all ownership data sequentially
if holders, err := c.GetTopShareHolders(id, count); err == nil {
ownership.TopHolders = holders
}
if buyers, err := c.GetTopBuyers(id, count); err == nil {
ownership.TopBuyers = buyers
}
if sellers, err := c.GetTopSellers(id, count); err == nil {
ownership.TopSellers = sellers
}
if newHolders, err := c.GetNewShareHolders(id, count); err == nil {
ownership.NewHolders = newHolders
}
if exited, err := c.GetExitedShareHolders(id, count); err == nil {
ownership.ExitedHolders = exited
}
return ownership, nil
}

1054
msn/db.go Normal file

File diff suppressed because it is too large Load diff

1844
msn/idx_stocks.go Normal file

File diff suppressed because it is too large Load diff

652
msn/msn_client.go Normal file
View file

@ -0,0 +1,652 @@
package msn
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/enetx/g"
"github.com/enetx/surf"
)
// MSNClientConfig holds configuration for the MSN client
type MSNClientConfig struct {
Proxy string // Proxy URL (http://, https://, socks5://)
RateLimiter *RateLimiter
}
// MSNClient is the base client for MSN Finance APIs
type MSNClient struct {
client *surf.Client
proxy string
rateLimiter *RateLimiter
}
// NewMSNClient creates a new MSN API client with Chrome impersonation
func NewMSNClient() *MSNClient {
return NewMSNClientWithConfig(MSNClientConfig{})
}
// NewMSNClientWithConfig creates a new MSN API client with custom configuration
func NewMSNClientWithConfig(config MSNClientConfig) *MSNClient {
builder := surf.NewClient().
Builder().
Impersonate().
Chrome()
// Add proxy if configured
if config.Proxy != "" {
builder = builder.Proxy(g.String(config.Proxy))
}
client := builder.Build().Unwrap()
return &MSNClient{
client: client,
proxy: config.Proxy,
rateLimiter: config.RateLimiter,
}
}
// waitForRateLimit waits for rate limiter if configured
func (c *MSNClient) waitForRateLimit() {
if c.rateLimiter != nil {
c.rateLimiter.Wait()
}
}
// Close closes idle connections
func (c *MSNClient) Close() {
c.client.CloseIdleConnections()
}
// commonHeaders returns common headers for MSN API requests
func (c *MSNClient) commonHeaders() map[string]string {
return map[string]string{
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9,id;q=0.8",
"Origin": "https://www.msn.com",
"Referer": "https://www.msn.com/",
}
}
// GetQuotes fetches real-time quotes for given stock IDs
func (c *MSNClient) GetQuotes(ids []string) ([]QuoteData, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Quotes?apikey=%s&ids=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("quotes request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("quotes API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var quotes []QuoteData
if err := json.Unmarshal([]byte(body), &quotes); err != nil {
return nil, fmt.Errorf("failed to parse quotes response: %w", err)
}
return quotes, nil
}
// GetQuoteSummary fetches detailed quote summary with multiple intents
func (c *MSNClient) GetQuoteSummary(id string, intents []string) (map[string]json.RawMessage, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
intentStr := strings.Join(intents, ",")
apiURL := fmt.Sprintf("%sFinance/QuoteSummary?apikey=%s&ids=%s&intents=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
id,
intentStr,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("quote summary request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("quote summary API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var result []map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse quote summary response: %w", err)
}
if len(result) == 0 {
return nil, fmt.Errorf("empty quote summary response")
}
return result[0], nil
}
// GetCharts fetches historical chart data
func (c *MSNClient) GetCharts(ids []string, chartType string) ([]ChartResponse, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Charts?apikey=%s&cm=id-id&ids=%s&type=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
chartType,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("charts request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("charts API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var charts []ChartResponse
if err := json.Unmarshal([]byte(body), &charts); err != nil {
return nil, fmt.Errorf("failed to parse charts response: %w", err)
}
return charts, nil
}
// GetEquities fetches company information
func (c *MSNClient) GetEquities(ids []string) ([]EquityData, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Equities?apikey=%s&ids=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("equities request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("equities API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var equities []EquityData
if err := json.Unmarshal([]byte(body), &equities); err != nil {
return nil, fmt.Errorf("failed to parse equities response: %w", err)
}
return equities, nil
}
// GetFinancialStatements fetches financial statements
func (c *MSNClient) GetFinancialStatements(id string) (FinancialStatementsResponse, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
// URL encode the filter parameter
filter := fmt.Sprintf("_p eq '%s'", id)
apiURL := fmt.Sprintf("%sFinance/Equities/financialstatements?apikey=%s&$filter=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
url.QueryEscape(filter),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("financial statements request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("financial statements API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
// Response is a direct array of FinancialStatement
var result FinancialStatementsResponse
if err := json.Unmarshal([]byte(body), &result); err != nil {
return nil, fmt.Errorf("failed to parse financial statements response: %w", err)
}
return result, nil
}
// GetEarnings fetches earnings events
func (c *MSNClient) GetEarnings(ids []string) ([]EarningsEvent, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Events/Earnings?apikey=%s&ids=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("earnings request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("earnings API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
// Parse the actual API response format
var apiResp EarningsAPIResponse
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse earnings response: %w", err)
}
// Convert quarterly earnings to EarningsEvent array
var earnings []EarningsEvent
for periodKey, data := range apiResp.History.Quarterly {
// Parse fiscal year and quarter from CiqFiscalPeriodType (e.g., "Q42025")
fiscalYear := 0
fiscalQuarter := 0
if len(data.CiqFiscalPeriodType) >= 6 {
// Format: Q{quarter}{year} e.g., Q42025
fmt.Sscanf(data.CiqFiscalPeriodType, "Q%d%d", &fiscalQuarter, &fiscalYear)
}
if fiscalYear == 0 && len(periodKey) >= 6 {
// Fallback: parse from period key (e.g., "202512")
fmt.Sscanf(periodKey[:4], "%d", &fiscalYear)
month := 0
fmt.Sscanf(periodKey[4:6], "%d", &month)
fiscalQuarter = (month-1)/3 + 1
}
// Parse event date
eventDate := ""
if data.EarningReleaseDate != "" {
// Extract date portion from ISO timestamp
if len(data.EarningReleaseDate) >= 10 {
eventDate = data.EarningReleaseDate[:10]
}
}
earnings = append(earnings, EarningsEvent{
ID: fmt.Sprintf("%s_%s", apiResp.InstrumentID, periodKey),
EventDate: eventDate,
FiscalYear: fiscalYear,
FiscalQuarter: fiscalQuarter,
EPSEstimate: data.EpsForecast,
EPSActual: data.EpsActual,
EPSSurprise: data.EpsSurprise,
EPSSurprisePct: data.EpsSurprisePercent,
RevenueEstimate: data.RevenueForecast,
RevenueActual: data.RevenueActual,
RevenueSurprise: data.RevenueSurprise,
})
}
return earnings, nil
}
// GetSentiment fetches market sentiment
func (c *MSNClient) GetSentiment(ids []string) ([]SentimentData, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/SentimentBrowser?apikey=%s&cm=id-id&it=web&scn=ANON&ids=%s&wrapodata=false&flightId=INeedDau",
MSNAssetsBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("sentiment request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("sentiment API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var sentiment []SentimentData
if err := json.Unmarshal([]byte(body), &sentiment); err != nil {
return nil, fmt.Errorf("failed to parse sentiment response: %w", err)
}
return sentiment, nil
}
// GetKeyRatios fetches key financial ratios from api.msn.com
func (c *MSNClient) GetKeyRatios(ids []string) ([]KeyRatios, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("no stock IDs provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%skeyratios?apikey=%s&ids=%s&wrapodata=false",
MSNAPIBaseURL,
MSNAPIKey,
strings.Join(ids, ","),
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("key ratios request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("key ratios API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var ratios []KeyRatios
if err := json.Unmarshal([]byte(body), &ratios); err != nil {
return nil, fmt.Errorf("failed to parse key ratios response: %w", err)
}
return ratios, nil
}
// GetInsights fetches AI-generated insights from api.msn.com
func (c *MSNClient) GetInsights(id string) (*InsightData, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sinsights?apikey=%s&ids=%s&wrapodata=false",
MSNAPIBaseURL,
MSNAPIKey,
id,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("insights request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("insights API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var insights []InsightData
if err := json.Unmarshal([]byte(body), &insights); err != nil {
return nil, fmt.Errorf("failed to parse insights response: %w", err)
}
if len(insights) == 0 {
return nil, nil
}
return &insights[0], nil
}
// GetNewsFeed fetches stock-related news
func (c *MSNClient) GetNewsFeed(id string) ([]NewsItem, error) {
if id == "" {
return nil, fmt.Errorf("no stock ID provided")
}
c.waitForRateLimit()
// Use the stock-specific entity feed format from MSN website
apiURL := fmt.Sprintf("%sMSN/Feed/me?$top=30&apikey=%s&cm=id-id&contentType=article,video,slideshow&it=web&query=ef_stock_%s&queryType=entityfeed&responseSchema=cardview&scn=ANON&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
id,
)
req := c.client.Get(g.String(apiURL))
for k, v := range c.commonHeaders() {
req = req.SetHeaders(k, v)
}
resp := req.Do()
if resp.IsErr() {
return nil, fmt.Errorf("news feed request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
return nil, fmt.Errorf("news feed API returned status %d", r.StatusCode)
}
body := r.Body.String().Ok().Std()
var newsFeed NewsFeedResponse
if err := json.Unmarshal([]byte(body), &newsFeed); err != nil {
return nil, fmt.Errorf("failed to parse news feed response: %w", err)
}
// Use SubCards if available (cardview response), otherwise use Value
if len(newsFeed.SubCards) > 0 {
return newsFeed.SubCards, nil
}
return newsFeed.Value, nil
}
// GetAllCharts fetches all chart timeframes for a stock
func (c *MSNClient) GetAllCharts(id string) (map[string][]ChartPoint, error) {
chartTypes := []string{"1D1M", "1M", "3M", "1Y", "3Y"}
result := make(map[string][]ChartPoint)
for _, chartType := range chartTypes {
charts, err := c.GetCharts([]string{id}, chartType)
if err != nil {
continue // Skip failed chart types
}
if len(charts) > 0 {
// Map chart type to friendlier names
typeName := chartType
switch chartType {
case "1D1M":
typeName = "1D"
}
result[typeName] = charts[0].Points
}
}
return result, nil
}
// FetchStockData fetches all data for a single stock
func (c *MSNClient) FetchStockData(id string) (*StockData, error) {
stock := &StockData{
ID: id,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
FetchStatus: make(map[string]string),
Charts: make(map[string][]ChartPoint),
}
// Fetch quote
quotes, err := c.GetQuotes([]string{id})
if err != nil {
stock.FetchStatus["quote"] = fmt.Sprintf("failed: %v", err)
} else if len(quotes) > 0 {
stock.Quote = &quotes[0]
stock.Ticker = quotes[0].Symbol
stock.Name = quotes[0].ShortName
stock.Exchange = quotes[0].ExchangeID
stock.FetchStatus["quote"] = "ok"
}
// Fetch company info
equities, err := c.GetEquities([]string{id})
if err != nil {
stock.FetchStatus["company"] = fmt.Sprintf("failed: %v", err)
} else if len(equities) > 0 {
stock.Company = &equities[0]
stock.Sector = equities[0].Sector
stock.Industry = equities[0].Industry
if stock.Name == "" {
stock.Name = equities[0].ShortName
}
stock.FetchStatus["company"] = "ok"
}
// Fetch charts
charts, err := c.GetAllCharts(id)
if err != nil {
stock.FetchStatus["charts"] = fmt.Sprintf("failed: %v", err)
} else {
stock.Charts = charts
stock.FetchStatus["charts"] = "ok"
}
// Fetch key ratios
ratios, err := c.GetKeyRatios([]string{id})
if err != nil {
stock.FetchStatus["key_ratios"] = fmt.Sprintf("failed: %v", err)
} else if len(ratios) > 0 {
stock.KeyRatios = &ratios[0]
stock.FetchStatus["key_ratios"] = "ok"
}
// Fetch earnings
earnings, err := c.GetEarnings([]string{id})
if err != nil {
stock.FetchStatus["earnings"] = fmt.Sprintf("failed: %v", err)
} else {
stock.Earnings = earnings
stock.FetchStatus["earnings"] = "ok"
}
// Fetch sentiment
sentiment, err := c.GetSentiment([]string{id})
if err != nil {
stock.FetchStatus["sentiment"] = fmt.Sprintf("failed: %v", err)
} else if len(sentiment) > 0 {
stock.Sentiment = &sentiment[0]
stock.FetchStatus["sentiment"] = "ok"
}
// Fetch insights
insights, err := c.GetInsights(id)
if err != nil {
stock.FetchStatus["insights"] = fmt.Sprintf("failed: %v", err)
} else if insights != nil {
stock.Insights = insights
stock.FetchStatus["insights"] = "ok"
}
// Fetch financial statements
financials, err := c.GetFinancialStatements(id)
if err != nil {
stock.FetchStatus["financials"] = fmt.Sprintf("failed: %v", err)
} else if len(financials) > 0 {
stock.Financials = &FinancialData{
Statements: financials,
}
stock.FetchStatus["financials"] = "ok"
}
// Fetch news
news, err := c.GetNewsFeed(id)
if err != nil {
stock.FetchStatus["news"] = fmt.Sprintf("failed: %v", err)
} else {
stock.News = news
stock.FetchStatus["news"] = "ok"
}
return stock, nil
}

228
msn/msn_screener.go Normal file
View file

@ -0,0 +1,228 @@
package msn
import (
"encoding/json"
"fmt"
"github.com/enetx/g"
)
// ScreenerFilter represents available screener filter presets
type ScreenerFilter string
const (
FilterTopPerformers ScreenerFilter = "top-performers"
FilterWorstPerformers ScreenerFilter = "worst-performers"
FilterHighDividend ScreenerFilter = "high-dividend"
FilterLowPE ScreenerFilter = "low-pe"
Filter52WeekHigh ScreenerFilter = "52w-high"
Filter52WeekLow ScreenerFilter = "52w-low"
FilterHighVolume ScreenerFilter = "high-volume"
FilterLargeMarketCap ScreenerFilter = "large-cap"
)
// Filter key mappings for MSN Screener API
var screenerFilterKeys = map[ScreenerFilter]string{
FilterTopPerformers: "st_list_topperfs",
FilterWorstPerformers: "st_list_poorperfs",
FilterHighDividend: "st_list_highdividend",
FilterLowPE: "st_list_lowpe",
Filter52WeekHigh: "st_list_52wkhi",
Filter52WeekLow: "st_list_52wklow",
FilterHighVolume: "st_list_highvol",
FilterLargeMarketCap: "st_list_largecap",
}
// Region key mappings for MSN Screener API
var screenerRegionKeys = map[string]string{
"id": "st_reg_id", // Indonesia
"us": "st_reg_us", // United States
"gb": "st_reg_gb", // United Kingdom
"de": "st_reg_de", // Germany
"jp": "st_reg_jp", // Japan
"hk": "st_reg_hk", // Hong Kong
"sg": "st_reg_sg", // Singapore
"au": "st_reg_au", // Australia
"in": "st_reg_in", // India
"cn": "st_reg_cn", // China
}
// ScreenerConfig holds screener configuration
type ScreenerConfig struct {
Region string // Country code (e.g., "id" for Indonesia)
Filter ScreenerFilter // Filter preset
Limit int // Max results
PageIndex int // Page number (0-indexed)
}
// ScreenerAPIResponse is the raw response from Finance/Screener
type ScreenerAPIResponse struct {
Count int `json:"count"`
MatchIDs []string `json:"matchIds"`
Quote []QuoteData `json:"quote"`
Equity []EquityData `json:"equity"`
Fund []interface{} `json:"fund"`
}
// RunScreener executes the stock screener with given configuration
func (c *MSNClient) RunScreener(config ScreenerConfig) (*ScreenerResponse, error) {
if config.Region == "" {
config.Region = "id" // Default to Indonesia
}
if config.Limit <= 0 {
config.Limit = 50
}
// Build filter array
filters := buildScreenerFilters(config.Region, config.Filter)
req := ScreenerRequest{
Filter: filters,
Order: ScreenerOrder{Key: "st_1yr_asc_order", Dir: "desc"},
ReturnValueType: []string{"quote", "equity"},
ScreenerType: "stock",
Limit: config.Limit,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal screener request: %w", err)
}
c.waitForRateLimit()
apiURL := fmt.Sprintf("%sFinance/Screener?apikey=%s&wrapodata=false",
MSNAssetsBaseURL,
MSNAPIKey,
)
httpReq := c.client.Post(g.String(apiURL)).
SetHeaders("Content-Type", "text/plain;charset=UTF-8")
for k, v := range c.commonHeaders() {
httpReq = httpReq.SetHeaders(k, v)
}
httpReq = httpReq.Body(g.String(string(reqBody)))
resp := httpReq.Do()
if resp.IsErr() {
return nil, fmt.Errorf("screener request failed: %w", resp.Err())
}
r := resp.Ok()
if r.StatusCode != 200 {
body := r.Body.String().Ok().Std()
return nil, fmt.Errorf("screener API returned status %d: %s", r.StatusCode, body)
}
body := r.Body.String().Ok().Std()
var apiResp ScreenerAPIResponse
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse screener response: %w", err)
}
// Merge quote and equity data into ScreenerStock
stocks := mergeScreenerResults(apiResp)
return &ScreenerResponse{
Value: stocks,
Total: apiResp.Count,
Count: apiResp.Count,
MatchIDs: apiResp.MatchIDs,
}, nil
}
// buildScreenerFilters creates filter array based on region and preset
func buildScreenerFilters(region string, filter ScreenerFilter) []ScreenerFilterItem {
filters := make([]ScreenerFilterItem, 0, 2)
// Add filter preset
if filterKey, ok := screenerFilterKeys[filter]; ok {
filters = append(filters, ScreenerFilterItem{
Key: filterKey,
KeyGroup: "st_list_",
IsRange: false,
})
}
// Add region filter
if regionKey, ok := screenerRegionKeys[region]; ok {
filters = append(filters, ScreenerFilterItem{
Key: regionKey,
KeyGroup: "st_reg_",
IsRange: false,
})
}
return filters
}
// mergeScreenerResults combines quote and equity data into ScreenerStock slice
func mergeScreenerResults(apiResp ScreenerAPIResponse) []ScreenerStock {
// Build equity map by instrumentId
equityMap := make(map[string]*EquityData)
for i := range apiResp.Equity {
eq := &apiResp.Equity[i]
// Use instrumentId from the "_p" field if available
if id := eq.ID; id != "" {
equityMap[id] = eq
}
}
stocks := make([]ScreenerStock, 0, len(apiResp.Quote))
for _, q := range apiResp.Quote {
stock := ScreenerStock{
ID: q.InstrumentID,
InstrumentID: q.InstrumentID,
Symbol: q.Symbol,
ShortName: q.ShortName,
DisplayName: q.DisplayName,
ExchangeID: q.ExchangeID,
ExchangeCode: q.ExchangeCode,
Country: q.Country,
Price: q.Price,
PriceChange: q.PriceChange,
PriceChangePct: q.PriceChangePct,
MarketCap: q.MarketCap,
Volume: q.AccumulatedVolume,
Price52wHigh: q.Price52wHigh,
Price52wLow: q.Price52wLow,
Return1Year: q.Return1Year,
ReturnYTD: q.ReturnYTD,
}
// Merge equity data if available
if eq, ok := equityMap[q.InstrumentID]; ok {
stock.Sector = eq.Sector
stock.Industry = eq.Industry
}
stocks = append(stocks, stock)
}
return stocks
}
// ParseScreenerFilter converts string to ScreenerFilter
func ParseScreenerFilter(s string) (ScreenerFilter, error) {
switch s {
case "top-performers", "top":
return FilterTopPerformers, nil
case "worst-performers", "worst":
return FilterWorstPerformers, nil
case "high-dividend", "dividend":
return FilterHighDividend, nil
case "low-pe", "pe":
return FilterLowPE, nil
case "52w-high", "52high":
return Filter52WeekHigh, nil
case "52w-low", "52low":
return Filter52WeekLow, nil
case "high-volume", "volume":
return FilterHighVolume, nil
case "large-cap", "largecap":
return FilterLargeMarketCap, nil
default:
return "", fmt.Errorf("unknown filter: %s (valid: top-performers, worst-performers, high-dividend, low-pe, 52w-high, 52w-low, high-volume, large-cap)", s)
}
}

353
msn/msn_stock.go Normal file
View file

@ -0,0 +1,353 @@
package msn
import (
"context"
"fmt"
"log"
"sync"
"time"
)
// StockFetcher handles parallel fetching of stock data
type StockFetcher struct {
msnClient *MSNClient
bingClient *BingClient
}
// NewStockFetcher creates a new stock fetcher
func NewStockFetcher() *StockFetcher {
return &StockFetcher{
msnClient: NewMSNClient(),
bingClient: NewBingClient(),
}
}
// Close closes all clients
func (f *StockFetcher) Close() {
f.msnClient.Close()
f.bingClient.Close()
}
// FetchResult holds the result of fetching a single stock
type StockFetchResult struct {
Index int
Stock *StockData
Error error
}
// FetchStocks fetches data for multiple stocks in parallel
func (f *StockFetcher) FetchStocks(ctx context.Context, ids []string, concurrency int) []StockData {
if concurrency <= 0 {
concurrency = 5
}
results := make([]StockData, len(ids))
// Create work channel
work := make(chan int, len(ids))
for i := range ids {
work <- i
}
close(work)
// Worker pool
var wg sync.WaitGroup
var mu sync.Mutex
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case idx, ok := <-work:
if !ok {
return
}
id := ids[idx]
stock := f.fetchSingleStock(ctx, id)
mu.Lock()
results[idx] = *stock
mu.Unlock()
// Count successful fetches
successCount := 0
for k, v := range stock.FetchStatus {
if v == "ok" {
successCount++
}
_ = k
}
log.Printf("[%d/%d] %s (%s) - %d/%d APIs succeeded",
idx+1, len(ids),
stock.Ticker,
stock.ID,
successCount,
len(stock.FetchStatus),
)
}
}
}()
}
wg.Wait()
return results
}
// fetchSingleStock fetches all data for a single stock
func (f *StockFetcher) fetchSingleStock(ctx context.Context, id string) *StockData {
stock := &StockData{
ID: id,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
FetchStatus: make(map[string]string),
Charts: make(map[string][]ChartPoint),
}
// Use channels for parallel fetching within a single stock
type fetchResult struct {
name string
err error
data interface{}
}
resultChan := make(chan fetchResult, 10)
var fetchWg sync.WaitGroup
// Fetch quote
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
quotes, err := f.msnClient.GetQuotes([]string{id})
if err != nil {
resultChan <- fetchResult{name: "quote", err: err}
return
}
if len(quotes) > 0 {
resultChan <- fetchResult{name: "quote", data: &quotes[0]}
}
}()
// Fetch company info
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
equities, err := f.msnClient.GetEquities([]string{id})
if err != nil {
resultChan <- fetchResult{name: "company", err: err}
return
}
if len(equities) > 0 {
resultChan <- fetchResult{name: "company", data: &equities[0]}
}
}()
// Fetch key ratios
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
ratios, err := f.msnClient.GetKeyRatios([]string{id})
if err != nil {
resultChan <- fetchResult{name: "key_ratios", err: err}
return
}
if len(ratios) > 0 {
resultChan <- fetchResult{name: "key_ratios", data: &ratios[0]}
}
}()
// Fetch earnings
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
earnings, err := f.msnClient.GetEarnings([]string{id})
if err != nil {
resultChan <- fetchResult{name: "earnings", err: err}
return
}
resultChan <- fetchResult{name: "earnings", data: earnings}
}()
// Fetch sentiment
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
sentiment, err := f.msnClient.GetSentiment([]string{id})
if err != nil {
resultChan <- fetchResult{name: "sentiment", err: err}
return
}
if len(sentiment) > 0 {
resultChan <- fetchResult{name: "sentiment", data: &sentiment[0]}
}
}()
// Fetch insights
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
insights, err := f.msnClient.GetInsights(id)
if err != nil {
resultChan <- fetchResult{name: "insights", err: err}
return
}
resultChan <- fetchResult{name: "insights", data: insights}
}()
// Fetch financial statements
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
financials, err := f.msnClient.GetFinancialStatements(id)
if err != nil {
resultChan <- fetchResult{name: "financials", err: err}
return
}
resultChan <- fetchResult{name: "financials", data: financials}
}()
// Fetch news
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
news, err := f.msnClient.GetNewsFeed(id)
if err != nil {
resultChan <- fetchResult{name: "news", err: err}
return
}
resultChan <- fetchResult{name: "news", data: news}
}()
// Fetch charts (all timeframes)
chartTypes := []string{"1D1M", "1M", "3M", "1Y", "3Y"}
for _, chartType := range chartTypes {
ct := chartType
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
charts, err := f.msnClient.GetCharts([]string{id}, ct)
if err != nil {
return // Skip failed chart types silently
}
if len(charts) > 0 {
points := charts[0].ToChartPoints()
if len(points) > 0 {
typeName := ct
if ct == "1D1M" {
typeName = "1D"
}
resultChan <- fetchResult{name: "chart_" + typeName, data: points}
}
}
}()
}
// Fetch ownership data from Bing
fetchWg.Add(1)
go func() {
defer fetchWg.Done()
ownership, err := f.bingClient.GetAllOwnership(id, 20)
if err != nil {
resultChan <- fetchResult{name: "ownership", err: err}
return
}
resultChan <- fetchResult{name: "ownership", data: ownership}
}()
// Close result channel when all fetches complete
go func() {
fetchWg.Wait()
close(resultChan)
}()
// Collect results
for result := range resultChan {
if result.err != nil {
stock.FetchStatus[result.name] = fmt.Sprintf("failed: %v", result.err)
continue
}
switch result.name {
case "quote":
if quote, ok := result.data.(*QuoteData); ok && quote != nil {
stock.Quote = quote
stock.Ticker = quote.Symbol
stock.Name = quote.ShortName
stock.Exchange = quote.ExchangeID
stock.FetchStatus["quote"] = "ok"
}
case "company":
if equity, ok := result.data.(*EquityData); ok && equity != nil {
stock.Company = equity
stock.Sector = equity.Sector
stock.Industry = equity.Industry
if stock.Name == "" {
stock.Name = equity.ShortName
}
stock.FetchStatus["company"] = "ok"
}
case "key_ratios":
if ratios, ok := result.data.(*KeyRatios); ok && ratios != nil {
stock.KeyRatios = ratios
stock.FetchStatus["key_ratios"] = "ok"
}
case "earnings":
if earnings, ok := result.data.([]EarningsEvent); ok {
stock.Earnings = earnings
stock.FetchStatus["earnings"] = "ok"
}
case "sentiment":
if sentiment, ok := result.data.(*SentimentData); ok && sentiment != nil {
stock.Sentiment = sentiment
stock.FetchStatus["sentiment"] = "ok"
}
case "insights":
if insights, ok := result.data.(*InsightData); ok && insights != nil {
stock.Insights = insights
stock.FetchStatus["insights"] = "ok"
}
case "financials":
if financials, ok := result.data.(FinancialStatementsResponse); ok && len(financials) > 0 {
stock.Financials = &FinancialData{
Statements: financials,
}
stock.FetchStatus["financials"] = "ok"
}
case "news":
if news, ok := result.data.([]NewsItem); ok {
stock.News = news
stock.FetchStatus["news"] = "ok"
}
case "ownership":
if ownership, ok := result.data.(*OwnershipData); ok && ownership != nil {
stock.Ownership = ownership
stock.FetchStatus["ownership"] = "ok"
}
default:
// Handle chart results
if len(result.name) > 6 && result.name[:6] == "chart_" {
chartType := result.name[6:]
if points, ok := result.data.([]ChartPoint); ok {
stock.Charts[chartType] = points
stock.FetchStatus["charts"] = "ok"
}
}
}
}
return stock
}
// FetchStockByID fetches a single stock by ID
func (f *StockFetcher) FetchStockByID(ctx context.Context, id string) (*StockData, error) {
stocks := f.FetchStocks(ctx, []string{id}, 1)
if len(stocks) == 0 {
return nil, fmt.Errorf("failed to fetch stock %s", id)
}
return &stocks[0], nil
}

550
msn/msn_types.go Normal file
View file

@ -0,0 +1,550 @@
package msn
// MSN API Constants
const (
MSNAssetsBaseURL = "https://assets.msn.com/service/"
MSNAPIBaseURL = "https://api.msn.com/msn/v0/pages/finance/"
BingAPIBaseURL = "https://services.bingapis.com/contentservices-finance.hedgefunddataprovider/api/v1/"
// Public API key from MSN Money website
MSNAPIKey = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM"
)
// ScreenerRequest is the POST body for Finance/Screener
// Uses the actual MSN API format with predefined filter keys
type ScreenerRequest struct {
Filter []ScreenerFilterItem `json:"filter"`
Order ScreenerOrder `json:"order"`
ReturnValueType []string `json:"returnValueType"`
ScreenerType string `json:"screenerType"`
Limit int `json:"limit"`
}
// ScreenerFilterItem represents a filter condition in the screener
type ScreenerFilterItem struct {
Key string `json:"key"` // e.g., "st_list_topperfs", "st_reg_id"
KeyGroup string `json:"keyGroup"` // e.g., "st_list_", "st_reg_"
IsRange bool `json:"isRange"`
}
// ScreenerOrder represents sort order for screener results
type ScreenerOrder struct {
Key string `json:"key"` // e.g., "st_1yr_asc_order"
Dir string `json:"dir"` // "asc" or "desc"
}
// ScreenerResponse from Finance/Screener
type ScreenerResponse struct {
Value []ScreenerStock `json:"value"`
Total int `json:"total"`
Count int `json:"count"`
MatchIDs []string `json:"matchIds"`
Equity []ScreenerStock `json:"equity"`
Quote []QuoteData `json:"quote"`
}
// ScreenerStock is a stock from screener results
type ScreenerStock struct {
ID string `json:"id"`
InstrumentID string `json:"instrumentId,omitempty"`
Symbol string `json:"symbol"`
ShortName string `json:"shortName"`
DisplayName string `json:"displayName,omitempty"`
ExchangeID string `json:"exchangeId"`
ExchangeCode string `json:"exchangeCode,omitempty"`
Country string `json:"country,omitempty"`
Sector string `json:"sector,omitempty"`
Industry string `json:"industry,omitempty"`
Price float64 `json:"price"`
PriceChange float64 `json:"priceChange"`
PriceChangePct float64 `json:"priceChangePercent"`
MarketCap float64 `json:"marketCap"`
Volume float64 `json:"accumulatedVolume"`
Price52wHigh float64 `json:"price52wHigh"`
Price52wLow float64 `json:"price52wLow"`
Return1Year float64 `json:"return1Year"`
ReturnYTD float64 `json:"returnYTD"`
}
// QuoteResponse from Finance/Quotes
type QuoteResponse []QuoteData
// QuoteData represents real-time quote data
type QuoteData struct {
ID string `json:"id"`
InstrumentID string `json:"instrumentId"`
Symbol string `json:"symbol"`
ShortName string `json:"shortName"`
DisplayName string `json:"displayName"`
Price float64 `json:"price"`
PriceChange float64 `json:"priceChange"`
PriceChangePct float64 `json:"priceChangePercent"`
PriceDayOpen float64 `json:"priceDayOpen"`
PriceDayHigh float64 `json:"priceDayHigh"`
PriceDayLow float64 `json:"priceDayLow"`
PricePreviousClose float64 `json:"pricePreviousClose"`
PriceClose float64 `json:"priceClose"`
Price52wHigh float64 `json:"price52wHigh"`
Price52wLow float64 `json:"price52wLow"`
AccumulatedVolume float64 `json:"accumulatedVolume"`
AverageVolume float64 `json:"averageVolume"`
MarketCap float64 `json:"marketCap"`
MarketCapCurrency string `json:"marketCapCurrency"`
ExchangeID string `json:"exchangeId"`
ExchangeCode string `json:"exchangeCode"`
ExchangeName string `json:"exchangeName"`
Currency string `json:"currency"`
Country string `json:"country"`
Market string `json:"market"`
TimeLastTraded string `json:"timeLastTraded"`
TimeLastUpdated string `json:"timeLastUpdated"`
// Historical price changes
PriceChange1Week float64 `json:"priceChange1Week"`
PriceChange1Month float64 `json:"priceChange1Month"`
PriceChange3Month float64 `json:"priceChange3Month"`
PriceChange6Month float64 `json:"priceChange6Month"`
PriceChangeYTD float64 `json:"priceChangeYTD"`
PriceChange1Year float64 `json:"priceChange1Year"`
// Historical returns (percentage)
Return1Week float64 `json:"return1Week"`
Return1Month float64 `json:"return1Month"`
Return3Month float64 `json:"return3Month"`
Return6Month float64 `json:"return6Month"`
ReturnYTD float64 `json:"returnYTD"`
Return1Year float64 `json:"return1Year"`
}
// QuoteSummaryResponse from Finance/QuoteSummary
type QuoteSummaryResponse []struct {
Quotes []QuoteData `json:"quotes"`
Exchanges []ExchangeData `json:"exchanges"`
Details []QuoteDetail `json:"quoteDetails"`
ChartData []ChartResponse `json:"charts"`
}
// ExchangeData from Finance/Exchanges
type ExchangeData struct {
ID string `json:"id"`
Name string `json:"name"`
Country string `json:"country"`
Timezone string `json:"timeZone"`
}
// QuoteDetail provides extended quote information
type QuoteDetail struct {
ID string `json:"id"`
Beta float64 `json:"beta"`
TrailingPE float64 `json:"trailingPE"`
ForwardPE float64 `json:"forwardPE"`
PriceToBook float64 `json:"priceToBook"`
PriceToSales float64 `json:"priceToSales"`
EnterpriseValue float64 `json:"enterpriseValue"`
EBITDA float64 `json:"ebitda"`
Revenue float64 `json:"revenue"`
GrossProfit float64 `json:"grossProfit"`
FreeCashFlow float64 `json:"freeCashFlow"`
DebtToEquity float64 `json:"debtToEquity"`
QuickRatio float64 `json:"quickRatio"`
CurrentRatio float64 `json:"currentRatio"`
ReturnOnEquity float64 `json:"returnOnEquity"`
ReturnOnAssets float64 `json:"returnOnAssets"`
ProfitMargin float64 `json:"profitMargin"`
OperatingMargin float64 `json:"operatingMargin"`
GrossMargin float64 `json:"grossMargin"`
}
// ChartResponse from Finance/Charts
type ChartResponse struct {
ID string `json:"_p"`
ChartType string `json:"chartType"` // "1D1M", "1M", "3M", "1Y", "3Y"
Symbol string `json:"symbol"`
Series ChartSeriesData `json:"series"`
Points []ChartPoint `json:"-"` // Computed from Series
}
// ChartSeriesData is the raw series data from the API
type ChartSeriesData struct {
TimeStamps []string `json:"timeStamps"`
Prices []float64 `json:"prices"`
OpenPrices []float64 `json:"openPrices"`
PricesHigh []float64 `json:"pricesHigh"`
PricesLow []float64 `json:"pricesLow"`
Volumes []float64 `json:"volumes"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
PriceHigh float64 `json:"priceHigh"`
PriceLow float64 `json:"priceLow"`
}
// ToChartPoints converts the series data into chart points
func (c *ChartResponse) ToChartPoints() []ChartPoint {
if len(c.Series.TimeStamps) == 0 {
return nil
}
points := make([]ChartPoint, len(c.Series.TimeStamps))
for i, ts := range c.Series.TimeStamps {
point := ChartPoint{Time: ts}
if i < len(c.Series.Prices) {
point.Price = c.Series.Prices[i]
point.Close = c.Series.Prices[i]
}
if i < len(c.Series.OpenPrices) {
point.Open = c.Series.OpenPrices[i]
}
if i < len(c.Series.PricesHigh) {
point.High = c.Series.PricesHigh[i]
}
if i < len(c.Series.PricesLow) {
point.Low = c.Series.PricesLow[i]
}
if i < len(c.Series.Volumes) {
point.Volume = int64(c.Series.Volumes[i])
}
points[i] = point
}
return points
}
// ChartPoint is a single data point in a chart
type ChartPoint struct {
Time string `json:"time"`
Price float64 `json:"price"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume int64 `json:"volume"`
}
// EquityResponse from Finance/Equities
type EquityResponse []EquityData
// EquityData represents company information
type EquityData struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
ShortName string `json:"shortName"`
LongName string `json:"longName"`
Description string `json:"description"`
Sector string `json:"sector"`
Industry string `json:"industry"`
Website string `json:"website"`
Employees int `json:"fullTimeEmployees"`
Address string `json:"address"`
City string `json:"city"`
Country string `json:"country"`
Phone string `json:"phone"`
Officers []Officer `json:"officers"`
}
// Officer represents a company executive
type Officer struct {
Name string `json:"name"`
Title string `json:"title"`
Age int `json:"age"`
YearBorn int `json:"yearBorn"`
TotalPay int64 `json:"totalPay"`
}
// FinancialStatementsResponse from Finance/Equities/financialstatements
// Response is an array of FinancialStatement objects
type FinancialStatementsResponse []FinancialStatement
// FinancialStatement represents comprehensive financial data
type FinancialStatement struct {
UnderlyingInstrument InstrumentInfo `json:"underlyingInstrument"`
BalanceSheets *BalanceSheet `json:"balanceSheets"`
CashFlow *CashFlowData `json:"cashFlow"`
IncomeStatements *IncomeStatement `json:"incomeStatements"`
}
// InstrumentInfo contains basic stock information
type InstrumentInfo struct {
InstrumentID string `json:"instrumentId"`
DisplayName string `json:"displayName"`
ShortName string `json:"shortName"`
ExchangeID string `json:"exchangeId"`
ExchangeCode string `json:"exchangeCode"`
SecurityType string `json:"securityType"`
Symbol string `json:"symbol"`
}
// BalanceSheet represents balance sheet data
type BalanceSheet struct {
CurrentAssets map[string]float64 `json:"currentAssets"`
LongTermAssets map[string]float64 `json:"longTermAssets"`
CurrentLiabilities map[string]float64 `json:"currentLiabilities"`
Equity map[string]float64 `json:"equity"`
Currency string `json:"currency"`
Source string `json:"source"`
SourceDate string `json:"sourceDate"`
ReportDate string `json:"reportDate"`
EndDate string `json:"endDate"`
}
// CashFlowData represents cash flow statement
type CashFlowData struct {
Financing map[string]float64 `json:"financing"`
Investing map[string]float64 `json:"investing"`
Operating map[string]float64 `json:"operating"`
Currency string `json:"currency"`
Source string `json:"source"`
EndDate string `json:"endDate"`
}
// IncomeStatement represents income statement data
type IncomeStatement struct {
Revenue map[string]float64 `json:"revenue"`
Expenses map[string]float64 `json:"expenses"`
Currency string `json:"currency"`
Source string `json:"source"`
EndDate string `json:"endDate"`
}
// KeyRatiosResponse from api.msn.com keyratios
type KeyRatiosResponse []KeyRatios
// KeyRatios represents financial ratios with historical data
type KeyRatios struct {
StockID string `json:"stockId"`
ExchangeID string `json:"exchangeId"`
Market string `json:"market"`
Industry string `json:"industry"`
DisplayName string `json:"displayName"`
ShortName string `json:"shortName"`
Symbol string `json:"symbol"`
IndustryMetrics []IndustryMetric `json:"industryMetrics"`
}
// IndustryMetric represents financial metrics for a specific year
type IndustryMetric struct {
Year string `json:"year"`
FiscalPeriodType string `json:"fiscalPeriodType"`
RevenuePerShare float64 `json:"revenuePerShare"`
EarningsPerShare float64 `json:"earningsPerShare"`
FreeCashFlowPerShare float64 `json:"freeCashFlowPerShare"`
DividendPerShare float64 `json:"dividendPerShare"`
BookValuePerShare float64 `json:"bookValuePerShare"`
RevenueGrowthRate float64 `json:"revenueGrowthRate"`
EarningsGrowthRate float64 `json:"earningsGrowthRate"`
GrossMargin float64 `json:"grossMargin"`
OperatingMargin float64 `json:"operatingMargin"`
NetMargin float64 `json:"netMargin"`
ROE float64 `json:"roe"`
ROIC float64 `json:"roic"`
ROA float64 `json:"returnOnAssetCurrent"`
DebtToEquityRatio float64 `json:"debtToEquityRatio"`
DebtToEBITDA float64 `json:"debtToEbitda"`
FinancialLeverage float64 `json:"financialLeverage"`
QuickRatio float64 `json:"quickRatio"`
CurrentRatio float64 `json:"currentRatio"`
AssetTurnover float64 `json:"assetTurnover"`
InventoryTurnover float64 `json:"inventoryTurnover"`
ReceivableTurnover float64 `json:"receivableTurnover"`
PayoutRatio float64 `json:"payoutRatio"`
PriceToSalesRatio float64 `json:"priceToSalesRatio"`
PriceToEarningsRatio float64 `json:"priceToEarningsRatio"`
PriceToCashFlowRatio float64 `json:"priceToCashFlowRatio"`
PriceToBookRatio float64 `json:"priceToBookRatio"`
EVToEBITDA float64 `json:"evEbitda"`
}
// EarningsAPIResponse represents the actual API response from Finance/Events/Earnings
type EarningsAPIResponse struct {
History struct {
Quarterly map[string]EarningsData `json:"quarterly"`
Annual map[string]EarningsData `json:"annual"`
} `json:"History"`
InstrumentID string `json:"InstrumentId"`
Symbol string `json:"Symbol"`
}
// EarningsData represents a single earnings report from the API
type EarningsData struct {
EpsActual float64 `json:"EpsActual"`
EpsSurprise float64 `json:"EpsSurprise"`
EpsSurprisePercent float64 `json:"EpsSurprisePercent"`
EpsForecast float64 `json:"EpsForecast"`
RevenueActual float64 `json:"RevenueActual"`
RevenueSurprise float64 `json:"RevenueSurprise"`
RevenueForecast float64 `json:"RevenueForecast"`
EarningReleaseDate string `json:"EarningReleaseDate"`
CiqFiscalPeriodType string `json:"CiqFiscalPeriodType"` // e.g., "Q42025", "Q12026"
CalendarPeriodType string `json:"CalendarPeriodType"`
}
// EarningsEvent represents a normalized earnings event for storage
type EarningsEvent struct {
ID string `json:"id"`
EventDate string `json:"eventDate"`
FiscalYear int `json:"fiscalYear"`
FiscalQuarter int `json:"fiscalQuarter"`
EPSEstimate float64 `json:"epsEstimate"`
EPSActual float64 `json:"epsActual"`
EPSSurprise float64 `json:"epsSurprise"`
EPSSurprisePct float64 `json:"epsSurprisePercent"`
RevenueEstimate float64 `json:"revenueEstimate"`
RevenueActual float64 `json:"revenueActual"`
RevenueSurprise float64 `json:"revenueSurprise"`
}
// SentimentResponse from Finance/SentimentBrowser
type SentimentResponse []SentimentData
// SentimentData represents market sentiment for a stock
type SentimentData struct {
DisplayName string `json:"displayName"`
Market string `json:"market"`
InstrumentID string `json:"instrumentId"`
Symbol string `json:"symbol"`
SentimentStatistics []SentimentStatistic `json:"sentimentStatistics"`
}
// SentimentStatistic represents sentiment data for a time period
type SentimentStatistic struct {
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
TimeRangeName string `json:"timeRangeName"`
TimeRangeEnum string `json:"timeRangeEnum"`
Bullish int `json:"bullish"`
Bearish int `json:"bearish"`
Neutral int `json:"neutral"`
BullishPercent float64 `json:"bullishPercent"`
BearishPercent float64 `json:"bearishPercent"`
NeutralPercent float64 `json:"neutralPercent"`
Scenario string `json:"scenairo"` // Note: API has typo "scenairo"
}
// InsightsResponse from api.msn.com insights
type InsightsResponse []InsightData
// InsightData represents AI-generated stock insights
type InsightData struct {
ID string `json:"id"`
Summary string `json:"summary"`
Highlights []string `json:"highlights"`
Risks []string `json:"risks"`
LastUpdated string `json:"lastUpdated"`
}
// NewsFeedResponse from MSN/Feed/me
type NewsFeedResponse struct {
Value []NewsItem `json:"value"`
SubCards []NewsItem `json:"subCards"`
}
// NewsItem represents a news article
type NewsItem struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"abstract"`
Provider *NewsProvider `json:"provider"`
PublishTime string `json:"publishedDateTime"`
Images []NewsImage `json:"images"`
ReadTimeMin int `json:"readTimeMin"`
}
// NewsProvider represents a news provider
type NewsProvider struct {
ID string `json:"id"`
Name string `json:"name"`
}
// NewsImage represents a news article image
type NewsImage struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
// Holder represents an institutional holder
type Holder struct {
Name string `json:"investorName"`
Type string `json:"investorType"`
SharesHeld int64 `json:"sharesHeld"`
SharesChange int64 `json:"sharesChange"`
SharesPct float64 `json:"sharesPercent"`
Value float64 `json:"value"`
ReportDate string `json:"reportDate"`
}
// OwnershipResponse from Bing API
type OwnershipResponse struct {
Records []Holder `json:"records"`
SecurityOwnerships []Holder `json:"securityOwnerships"`
Total int `json:"total"`
}
// OwnershipData aggregates all ownership information
type OwnershipData struct {
TopHolders []Holder `json:"top_holders"`
TopBuyers []Holder `json:"top_buyers"`
TopSellers []Holder `json:"top_sellers"`
NewHolders []Holder `json:"new_holders"`
ExitedHolders []Holder `json:"exited_holders"`
}
// StockData is the complete stock information output
type StockData struct {
ID string `json:"id"`
Ticker string `json:"ticker"`
Name string `json:"name"`
Exchange string `json:"exchange"`
Sector string `json:"sector"`
Industry string `json:"industry"`
// Real-time data
Quote *QuoteData `json:"quote,omitempty"`
// Historical Charts
Charts map[string][]ChartPoint `json:"charts,omitempty"`
// Fundamentals
Financials *FinancialData `json:"financials,omitempty"`
KeyRatios *KeyRatios `json:"key_ratios,omitempty"`
// Company Info
Company *EquityData `json:"company,omitempty"`
// Events
Earnings []EarningsEvent `json:"earnings,omitempty"`
// Analysis
Sentiment *SentimentData `json:"sentiment,omitempty"`
Insights *InsightData `json:"insights,omitempty"`
// Ownership (Bing API)
Ownership *OwnershipData `json:"ownership,omitempty"`
// News
News []NewsItem `json:"news,omitempty"`
// Metadata
FetchedAt string `json:"fetched_at"`
FetchStatus map[string]string `json:"fetch_status"`
}
// FinancialData aggregates all financial statements
type FinancialData struct {
Statements []FinancialStatement `json:"statements,omitempty"`
}
// ScreenerOutput is the JSON output for screener command
type ScreenerOutput struct {
Filter string `json:"filter"`
Region string `json:"region"`
GeneratedAt string `json:"generated_at"`
Total int `json:"total"`
Stocks []ScreenerStock `json:"stocks"`
}
// FetchOutput is the JSON output for fetch command
type FetchOutput struct {
GeneratedAt string `json:"generated_at"`
Total int `json:"total"`
Stocks []StockData `json:"stocks"`
}

194
msn/news_analysis.go Normal file
View file

@ -0,0 +1,194 @@
package msn
import (
"strings"
)
// News categories
const (
CategoryEarnings = "earnings"
CategoryDividend = "dividend"
CategoryCorporateAction = "corporate_action"
CategoryRegulation = "regulation"
CategoryRating = "rating"
CategoryExpansion = "expansion"
CategoryLeadership = "leadership"
CategoryMarket = "market"
CategoryGeneral = "general"
)
// Sentiment types
const (
SentimentPositive = "positive"
SentimentNegative = "negative"
SentimentNeutral = "neutral"
)
// Category keywords (Indonesian + English)
var categoryKeywords = map[string][]string{
CategoryEarnings: {
"laba", "rugi", "earnings", "profit", "net income", "pendapatan",
"revenue", "keuntungan", "kerugian", "loss", "income", "untung",
"quarterly", "kuartalan", "annual report", "laporan tahunan",
"eps", "earning per share",
},
CategoryDividend: {
"dividen", "dividend", "pembagian", "interim", "final dividend",
"cum date", "ex date", "payment date", "tanggal pembayaran",
"yield", "payout",
},
CategoryCorporateAction: {
"akuisisi", "merger", "acquisition", "rights issue", "stock split",
"reverse split", "buyback", "ipo", "penawaran umum", "private placement",
"tender offer", "spin off", "spinoff", "demerger", "konsolidasi",
"rights", "waran", "warrant", "obligasi", "bond", "sukuk",
},
CategoryRegulation: {
"ojk", "regulasi", "peraturan", "kebijakan", "regulation", "policy",
"compliance", "kepatuhan", "lisensi", "license", "izin", "permit",
"pemerintah", "government", "bapepam", "bei", "idx", "bursa",
},
CategoryRating: {
"rating", "peringkat", "upgrade", "downgrade", "outlook",
"stable", "positive", "negative", "credit rating", "moody",
"fitch", "s&p", "pefindo", "target price", "rekomendasi",
"buy", "sell", "hold", "analyst",
},
CategoryExpansion: {
"ekspansi", "expansion", "investasi", "investment", "proyek baru",
"new project", "pabrik", "factory", "plant", "cabang", "branch",
"pembangunan", "construction", "development", "joint venture", "jv",
"kerjasama", "partnership", "kontrak", "contract",
},
CategoryLeadership: {
"direktur", "director", "komisaris", "commissioner", "ceo", "cfo",
"president director", "management", "manajemen", "direksi",
"rups", "agm", "annual general meeting", "pengangkatan", "appointment",
"pengunduran", "resignation", "pergantian", "change",
},
CategoryMarket: {
"ihsg", "idx", "pasar modal", "bursa", "market", "saham",
"stock", "trading", "perdagangan", "volume", "kapitalisasi",
"market cap", "blue chip", "lq45", "idx80", "kompas100",
},
}
// Positive sentiment keywords
var positiveKeywords = []string{
// Indonesian
"naik", "untung", "tumbuh", "positif", "optimis", "meningkat",
"surplus", "berhasil", "sukses", "cemerlang", "bagus", "baik",
"membaik", "melonjak", "meroket", "tertinggi", "rekor",
"peningkatan", "pertumbuhan", "keuntungan", "laba bersih",
"ekspansi", "pemulihan", "recovery",
// English
"rise", "gain", "growth", "positive", "optimistic", "increase",
"surplus", "success", "excellent", "good", "improve", "surge",
"soar", "highest", "record", "profit", "expansion", "recovery",
"bullish", "upgrade", "beat", "exceed", "outperform",
}
// Negative sentiment keywords
var negativeKeywords = []string{
// Indonesian
"turun", "rugi", "anjlok", "negatif", "pesimis", "menurun",
"defisit", "gagal", "buruk", "memburuk", "jatuh", "tertekan",
"terendah", "penurunan", "kerugian", "merosot", "melemah",
"default", "bangkrut", "pailit", "koreksi", "tekanan",
// English
"fall", "loss", "plunge", "negative", "pessimistic", "decrease",
"deficit", "fail", "bad", "worsen", "drop", "pressure",
"lowest", "decline", "weak", "default", "bankrupt", "correction",
"bearish", "downgrade", "miss", "underperform", "concern", "risk",
}
// Critical news keywords (alerts)
var criticalKeywords = []string{
// Indonesian
"suspend", "suspensi", "fraud", "penipuan", "korupsi", "corruption",
"default", "gagal bayar", "bangkrut", "pailit", "bankruptcy",
"delisting", "pencabutan", "investigasi", "investigation",
"skandal", "scandal", "illegal", "ilegal", "pelanggaran", "violation",
"tuntutan", "lawsuit", "gugatan", "denda", "fine", "sanksi", "sanction",
"pkpu", "penundaan", "moratorium", "restrukturisasi utang",
// English
"suspend", "fraud", "corruption", "default", "bankrupt", "bankruptcy",
"delisting", "investigation", "scandal", "illegal", "violation",
"lawsuit", "fine", "sanction", "debt restructuring", "warning",
"material adverse", "going concern", "audit opinion", "disclaimer",
}
// categorizeNews determines the category of a news article
func categorizeNews(title, abstract string) string {
text := strings.ToLower(title + " " + abstract)
// Check each category
maxScore := 0
bestCategory := CategoryGeneral
for category, keywords := range categoryKeywords {
score := 0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score++
}
}
if score > maxScore {
maxScore = score
bestCategory = category
}
}
return bestCategory
}
// scoreNewsSentiment analyzes sentiment of a news article
func scoreNewsSentiment(title, abstract string) (sentiment string, score float64) {
text := strings.ToLower(title + " " + abstract)
positiveScore := 0
negativeScore := 0
for _, keyword := range positiveKeywords {
if strings.Contains(text, keyword) {
positiveScore++
}
}
for _, keyword := range negativeKeywords {
if strings.Contains(text, keyword) {
negativeScore++
}
}
totalScore := positiveScore + negativeScore
if totalScore == 0 {
return SentimentNeutral, 0.0
}
// Calculate score from -1 (very negative) to +1 (very positive)
score = float64(positiveScore-negativeScore) / float64(totalScore)
if score > 0.2 {
sentiment = SentimentPositive
} else if score < -0.2 {
sentiment = SentimentNegative
} else {
sentiment = SentimentNeutral
}
return sentiment, score
}
// isNewsCritical checks if news contains critical/alert-worthy content
func isNewsCritical(title, abstract string) bool {
text := strings.ToLower(title + " " + abstract)
for _, keyword := range criticalKeywords {
if strings.Contains(text, keyword) {
return true
}
}
return false
}

97
msn/rate_limiter.go Normal file
View file

@ -0,0 +1,97 @@
package msn
import (
"math/rand"
"sync"
"time"
)
// RateLimiter implements a token bucket rate limiter with random delay
type RateLimiter struct {
mu sync.Mutex
tokens float64
maxTokens float64
refillRate float64 // tokens per second
lastRefill time.Time
minDelayMs int // minimum delay in milliseconds
maxDelayMs int // maximum delay in milliseconds
requestCount int64
}
// RateLimiterConfig holds rate limiter configuration
type RateLimiterConfig struct {
RequestsPerSecond float64 // target RPS
MinDelayMs int // minimum random delay
MaxDelayMs int // maximum random delay
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(config RateLimiterConfig) *RateLimiter {
if config.RequestsPerSecond <= 0 {
config.RequestsPerSecond = 10 // default 10 RPS
}
return &RateLimiter{
tokens: config.RequestsPerSecond, // start with full bucket
maxTokens: config.RequestsPerSecond,
refillRate: config.RequestsPerSecond,
lastRefill: time.Now(),
minDelayMs: config.MinDelayMs,
maxDelayMs: config.MaxDelayMs,
}
}
// Wait blocks until a token is available and applies random delay
func (r *RateLimiter) Wait() {
r.mu.Lock()
defer r.mu.Unlock()
// Refill tokens based on elapsed time
now := time.Now()
elapsed := now.Sub(r.lastRefill).Seconds()
r.tokens += elapsed * r.refillRate
if r.tokens > r.maxTokens {
r.tokens = r.maxTokens
}
r.lastRefill = now
// Wait if no tokens available
if r.tokens < 1 {
waitTime := time.Duration((1-r.tokens)/r.refillRate*1000) * time.Millisecond
r.mu.Unlock()
time.Sleep(waitTime)
r.mu.Lock()
r.tokens = 0
} else {
r.tokens--
}
r.requestCount++
// Apply random delay if configured
if r.maxDelayMs > 0 {
delayRange := r.maxDelayMs - r.minDelayMs
if delayRange <= 0 {
delayRange = 1
}
delay := r.minDelayMs + rand.Intn(delayRange)
r.mu.Unlock()
time.Sleep(time.Duration(delay) * time.Millisecond)
r.mu.Lock()
}
}
// RequestCount returns the total number of requests made
func (r *RateLimiter) RequestCount() int64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.requestCount
}
// SetRPS dynamically adjusts the rate limit
func (r *RateLimiter) SetRPS(rps float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.maxTokens = rps
r.refillRate = rps
}

12
pyproject.toml Normal file
View file

@ -0,0 +1,12 @@
[project]
name = "rubick"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.12"
dependencies = [
"lxml-html-clean>=0.4.3",
"newspaper4k>=0.9.4.1",
"openpyxl>=3.1.5",
"pandas>=3.0.0",
"xlsxwriter>=3.2.9",
]

31
scripts/e2e_run.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
APP_BIN="${1:-./bin/rubick}"
if [[ ! -x "$APP_BIN" ]]; then
echo "error: binary not executable: $APP_BIN" >&2
exit 1
fi
TS="$(date +%Y%m%d-%H%M%S)"
OUT="output/$TS"
mkdir -p "$OUT"
echo "timestamp=$TS" > "$OUT/RUN_INFO.txt"
echo "[1/5] msn fetch-all"
"$APP_BIN" msn fetch-all --index idx30 --limit 3 --db "$OUT/stocks.db" --rps 10 --delay 100-150 --concurrency 2
echo "[2/5] news plain"
"$APP_BIN" news IHSG --from 2026-03-01 --to 2026-03-05 --count 2 --concurrency 2 --output "$OUT/news_plain.json"
echo "[3/5] export simple csv"
"$APP_BIN" export simple --db "$OUT/stocks.db" --format csv --output "$OUT/csv"
echo "[4/5] export dashboard"
"$APP_BIN" export dashboard --db "$OUT/stocks.db" --output "$OUT/dashboard.xlsx"
echo "[5/5] export history"
"$APP_BIN" export history --db "$OUT/stocks.db" --output "$OUT/history.xlsx"
echo "E2E completed: $OUT"

1373
scripts/export_dashboard.py Normal file

File diff suppressed because it is too large Load diff

626
scripts/export_history.py Normal file
View file

@ -0,0 +1,626 @@
#!/usr/bin/env python3
"""
History Excel Export Script - Professional Edition
Exports historical data (price history, ratio history, sentiment history) to Excel
with proper tables and conditional formatting. No charts - tables only.
Usage:
uv run python scripts/export_history.py --db output/stocks.db --output output/history.xlsx
"""
import argparse
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Optional
import xlsxwriter
from xlsxwriter.utility import xl_range
# Color palette - matches export_dashboard.py
COLORS = {
'primary_dark': '#1F4E79',
'primary': '#2E75B6',
'primary_light': '#5B9BD5',
'success_dark': '#375623',
'success': '#70AD47',
'success_light': '#C6EFCE',
'success_text': '#006100',
'danger_dark': '#833C0C',
'danger': '#C00000',
'danger_light': '#FFC7CE',
'danger_text': '#9C0006',
'warning_dark': '#7F6000',
'warning': '#FFC000',
'warning_light': '#FFEB9C',
'warning_text': '#9C5700',
'white': '#FFFFFF',
'light_gray': '#F2F2F2',
'dark_gray': '#404040',
}
TABLE_STYLE = 'Table Style Medium 2'
def safe_float(value, default=None) -> Optional[float]:
"""Safely convert to float."""
if value is None:
return default
try:
return float(value)
except (ValueError, TypeError):
return default
class HistoryExporter:
"""Professional History Excel Exporter."""
def __init__(self, workbook: xlsxwriter.Workbook, conn: sqlite3.Connection,
start_date: str = None, end_date: str = None, stock_id: str = None):
self.wb = workbook
self.conn = conn
self.start_date = start_date
self.end_date = end_date
self.stock_id = stock_id
self._setup_formats()
def _setup_formats(self):
"""Setup formatting styles - matches export_dashboard.py."""
self.fmt_title = self.wb.add_format({
'bold': True, 'font_size': 18, 'font_color': COLORS['primary_dark'],
'bottom': 2, 'bottom_color': COLORS['primary_dark']
})
self.fmt_subtitle = self.wb.add_format({
'bold': True, 'font_size': 12, 'font_color': COLORS['primary']
})
self.fmt_section = self.wb.add_format({
'bold': True, 'font_size': 14, 'font_color': COLORS['white'],
'bg_color': COLORS['primary_dark'], 'align': 'center', 'valign': 'vcenter'
})
self.fmt_header = self.wb.add_format({
'bold': True, 'font_color': COLORS['white'],
'bg_color': COLORS['primary_dark'], 'align': 'center', 'valign': 'vcenter',
'border': 1, 'text_wrap': True
})
self.fmt_num = self.wb.add_format({'num_format': '#,##0.00', 'align': 'right'})
self.fmt_num_0 = self.wb.add_format({'num_format': '#,##0', 'align': 'right'})
self.fmt_pct = self.wb.add_format({'num_format': '0.00"%"', 'align': 'right'})
self.fmt_pct_signed = self.wb.add_format({'num_format': '+0.00%;-0.00%;0.00%', 'align': 'right'})
self.fmt_date = self.wb.add_format({'num_format': 'yyyy-mm-dd', 'align': 'center'})
self.fmt_kpi_value = self.wb.add_format({
'bold': True, 'font_size': 24, 'font_color': COLORS['primary_dark'],
'align': 'center', 'valign': 'vcenter'
})
self.fmt_kpi_label = self.wb.add_format({
'font_size': 10, 'font_color': COLORS['dark_gray'],
'align': 'center', 'valign': 'vcenter', 'bold': True
})
self.fmt_kpi_box = self.wb.add_format({
'bg_color': COLORS['light_gray'], 'border': 1, 'border_color': COLORS['primary_light']
})
def _add_table(self, ws, start_row: int, start_col: int, data: list,
columns: list, table_name: str, total_row: bool = False) -> int:
"""Add a proper Excel Table with filtering and sorting."""
if not data:
ws.write(start_row, start_col, "No data available")
return start_row + 1
end_row = start_row + len(data)
end_col = start_col + len(columns) - 1
# Build table columns
table_columns = []
for col_def in columns:
col_opt = {'header': col_def['header']}
if col_def.get('total_function'):
col_opt['total_function'] = col_def['total_function']
if col_def.get('total_string'):
col_opt['total_string'] = col_def['total_string']
if col_def.get('format'):
col_opt['format'] = col_def['format']
table_columns.append(col_opt)
# Write data
for row_idx, row_data in enumerate(data):
for col_idx, col_def in enumerate(columns):
key = col_def.get('key')
value = row_data.get(key) if key else None
transform = col_def.get('transform')
if transform and value is not None:
value = transform(value)
fmt = col_def.get('format')
ws.write(start_row + 1 + row_idx, start_col + col_idx, value, fmt)
# Add table
table_range = xl_range(start_row, start_col, end_row, end_col)
ws.add_table(table_range, {
'name': table_name,
'style': TABLE_STYLE,
'columns': table_columns,
'total_row': total_row,
'autofilter': True,
})
# Set column widths
for col_idx, col_def in enumerate(columns):
width = col_def.get('width', 12)
ws.set_column(start_col + col_idx, start_col + col_idx, width)
return end_row + (2 if total_row else 1)
def _get_stocks(self) -> list:
"""Get all stocks."""
cursor = self.conn.execute("""
SELECT id, ticker, name, exchange_code as exchange
FROM stocks ORDER BY ticker
""")
return [dict(row) for row in cursor.fetchall()]
def _get_price_history(self) -> list:
"""Get price history with filters."""
query = """
SELECT ph.*, s.ticker, s.name
FROM price_history ph
JOIN stocks s ON ph.stock_id = s.id
WHERE 1=1
"""
params = []
if self.stock_id:
query += " AND ph.stock_id = ?"
params.append(self.stock_id)
if self.start_date:
query += " AND ph.scrape_date >= ?"
params.append(self.start_date)
if self.end_date:
query += " AND ph.scrape_date <= ?"
params.append(self.end_date)
query += " ORDER BY s.ticker, ph.scrape_date DESC"
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
def _get_ratios_history(self) -> list:
"""Get ratios history with filters."""
query = """
SELECT rh.*, s.ticker, s.name
FROM ratios_history rh
JOIN stocks s ON rh.stock_id = s.id
WHERE 1=1
"""
params = []
if self.stock_id:
query += " AND rh.stock_id = ?"
params.append(self.stock_id)
if self.start_date:
query += " AND rh.scrape_date >= ?"
params.append(self.start_date)
if self.end_date:
query += " AND rh.scrape_date <= ?"
params.append(self.end_date)
query += " ORDER BY s.ticker, rh.scrape_date DESC"
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
def _get_sentiment_history(self) -> list:
"""Get sentiment history with filters."""
query = """
SELECT sh.*, s.ticker, s.name
FROM sentiment_history sh
JOIN stocks s ON sh.stock_id = s.id
WHERE 1=1
"""
params = []
if self.stock_id:
query += " AND sh.stock_id = ?"
params.append(self.stock_id)
if self.start_date:
query += " AND sh.scrape_date >= ?"
params.append(self.start_date)
if self.end_date:
query += " AND sh.scrape_date <= ?"
params.append(self.end_date)
query += " ORDER BY s.ticker, sh.scrape_date DESC"
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
def _get_scrape_runs(self) -> list:
"""Get scrape runs."""
cursor = self.conn.execute("""
SELECT * FROM scrape_runs ORDER BY started_at DESC
""")
return [dict(row) for row in cursor.fetchall()]
def create_summary_sheet(self, stocks: list, price_history: list,
ratios_history: list, sentiment_history: list,
scrape_runs: list):
"""Create summary sheet."""
ws = self.wb.add_worksheet("Summary")
# Title
ws.merge_range('A1:F1', "Historical Data Summary", self.fmt_title)
ws.set_row(0, 30)
ws.write('A2', f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", self.fmt_subtitle)
# KPI cards
row = 4
kpis = [
("Total Stocks", str(len(stocks))),
("Price Records", str(len(price_history))),
("Ratio Records", str(len(ratios_history))),
("Sentiment Records", str(len(sentiment_history))),
("Scrape Runs", str(len(scrape_runs))),
]
col = 0
for label, value in kpis:
ws.merge_range(row, col, row + 1, col + 1, '', self.fmt_kpi_box)
ws.write(row, col, label, self.fmt_kpi_label)
ws.write(row + 1, col, value, self.fmt_kpi_value)
col += 2
# Date range
row = 7
ws.write(row, 0, "Data Date Range:", self.fmt_subtitle)
if price_history:
dates = [p['scrape_date'] for p in price_history if p.get('scrape_date')]
if dates:
ws.write(row + 1, 0, f"From: {min(dates)}")
ws.write(row + 2, 0, f"To: {max(dates)}")
# Scrape runs summary table
row = 11
ws.merge_range(row, 0, row, 5, "Recent Scrape Runs", self.fmt_section)
row += 1
run_data = []
for r in scrape_runs[:10]:
started = r.get('started_at', '')
finished = r.get('finished_at', '')
duration = ''
if started and finished:
try:
start_dt = datetime.fromisoformat(started.replace('Z', '+00:00'))
finish_dt = datetime.fromisoformat(finished.replace('Z', '+00:00'))
delta = finish_dt - start_dt
duration = str(delta)
except:
pass
run_data.append({
'id': r.get('id'),
'status': r.get('status'),
'index': r.get('index_name'),
'total': r.get('total_stocks'),
'success': r.get('success'),
'failed': r.get('failed'),
'duration': duration,
})
run_cols = [
{'header': 'Run ID', 'key': 'id', 'width': 8},
{'header': 'Status', 'key': 'status', 'width': 12},
{'header': 'Index', 'key': 'index', 'width': 10},
{'header': 'Total', 'key': 'total', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Success', 'key': 'success', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Failed', 'key': 'failed', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Duration', 'key': 'duration', 'width': 15},
]
self._add_table(ws, row, 0, run_data, run_cols, 'ScrapeRuns')
ws.set_column('A:G', 12)
def create_price_history_sheet(self, data: list):
"""Create price history sheet."""
ws = self.wb.add_worksheet("Price History")
ws.merge_range('A1:N1', "Price History", self.fmt_title)
ws.set_row(0, 25)
row = 3
price_data = [{
'ticker': p['ticker'],
'name': p['name'],
'date': p.get('scrape_date', ''),
'price': safe_float(p.get('price')),
'change': safe_float(p.get('price_change')),
'change_pct': safe_float(p.get('price_change_pct'), 0) / 100 if p.get('price_change_pct') else None,
'open': safe_float(p.get('price_open')),
'high': safe_float(p.get('price_high')),
'low': safe_float(p.get('price_low')),
'volume': safe_float(p.get('volume')),
'market_cap': safe_float(p.get('market_cap')),
'high_52w': safe_float(p.get('price_52w_high')),
'low_52w': safe_float(p.get('price_52w_low')),
'ytd_pct': safe_float(p.get('price_change_ytd'), 0) / 100 if p.get('price_change_ytd') else None,
} for p in data]
price_cols = [
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
{'header': 'Name', 'key': 'name', 'width': 22},
{'header': 'Date', 'key': 'date', 'width': 11},
{'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_num},
{'header': 'Change', 'key': 'change', 'width': 10, 'format': self.fmt_num},
{'header': 'Chg%', 'key': 'change_pct', 'width': 8, 'format': self.fmt_pct_signed},
{'header': 'Open', 'key': 'open', 'width': 10, 'format': self.fmt_num},
{'header': 'High', 'key': 'high', 'width': 10, 'format': self.fmt_num},
{'header': 'Low', 'key': 'low', 'width': 10, 'format': self.fmt_num},
{'header': 'Volume', 'key': 'volume', 'width': 14, 'format': self.fmt_num_0},
{'header': 'Market Cap', 'key': 'market_cap', 'width': 15, 'format': self.fmt_num_0},
{'header': '52W High', 'key': 'high_52w', 'width': 10, 'format': self.fmt_num},
{'header': '52W Low', 'key': 'low_52w', 'width': 10, 'format': self.fmt_num},
{'header': 'YTD%', 'key': 'ytd_pct', 'width': 8, 'format': self.fmt_pct_signed},
]
self._add_table(ws, row, 0, price_data, price_cols, 'PriceHistory')
# Conditional formatting on change %
if price_data:
data_end = row + len(price_data)
ws.conditional_format(row + 1, 5, data_end, 5, {
'type': '3_color_scale',
'min_color': '#F8696B',
'mid_color': '#FFEB84',
'max_color': '#63BE7B',
})
ws.freeze_panes(4, 2)
def create_ratios_history_sheet(self, data: list):
"""Create ratios history sheet."""
ws = self.wb.add_worksheet("Ratios History")
ws.merge_range('A1:W1', "Financial Ratios History", self.fmt_title)
ws.set_row(0, 25)
row = 3
ratio_data = [{
'ticker': r['ticker'],
'name': r['name'],
'date': r.get('scrape_date', ''),
'year': r.get('year', ''),
'pe': safe_float(r.get('pe_ratio')),
'pb': safe_float(r.get('pb_ratio')),
'ps': safe_float(r.get('ps_ratio')),
'pcf': safe_float(r.get('pcf_ratio')),
'ev_ebitda': safe_float(r.get('ev_ebitda')),
'roe': safe_float(r.get('roe')),
'roa': safe_float(r.get('roa')),
'roic': safe_float(r.get('roic')),
'gross': safe_float(r.get('gross_margin')),
'op_margin': safe_float(r.get('operating_margin')),
'net_margin': safe_float(r.get('net_margin')),
'de': safe_float(r.get('debt_to_equity')),
'current': safe_float(r.get('current_ratio')),
'quick': safe_float(r.get('quick_ratio')),
'div_yield': safe_float(r.get('dividend_yield')),
'payout': safe_float(r.get('payout_ratio')),
'eps': safe_float(r.get('eps')),
'bvps': safe_float(r.get('bvps')),
'rev_gr': safe_float(r.get('revenue_growth')),
} for r in data]
ratio_cols = [
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
{'header': 'Name', 'key': 'name', 'width': 20},
{'header': 'Date', 'key': 'date', 'width': 11},
{'header': 'Year', 'key': 'year', 'width': 6},
{'header': 'P/E', 'key': 'pe', 'width': 7, 'format': self.fmt_num},
{'header': 'P/B', 'key': 'pb', 'width': 7, 'format': self.fmt_num},
{'header': 'P/S', 'key': 'ps', 'width': 7, 'format': self.fmt_num},
{'header': 'P/CF', 'key': 'pcf', 'width': 7, 'format': self.fmt_num},
{'header': 'EV/EBITDA', 'key': 'ev_ebitda', 'width': 9, 'format': self.fmt_num},
{'header': 'ROE%', 'key': 'roe', 'width': 7, 'format': self.fmt_num},
{'header': 'ROA%', 'key': 'roa', 'width': 7, 'format': self.fmt_num},
{'header': 'ROIC%', 'key': 'roic', 'width': 7, 'format': self.fmt_num},
{'header': 'Gross%', 'key': 'gross', 'width': 8, 'format': self.fmt_num},
{'header': 'Op%', 'key': 'op_margin', 'width': 7, 'format': self.fmt_num},
{'header': 'Net%', 'key': 'net_margin', 'width': 7, 'format': self.fmt_num},
{'header': 'D/E', 'key': 'de', 'width': 7, 'format': self.fmt_num},
{'header': 'Current', 'key': 'current', 'width': 8, 'format': self.fmt_num},
{'header': 'Quick', 'key': 'quick', 'width': 7, 'format': self.fmt_num},
{'header': 'Yield%', 'key': 'div_yield', 'width': 7, 'format': self.fmt_num},
{'header': 'Payout%', 'key': 'payout', 'width': 8, 'format': self.fmt_num},
{'header': 'EPS', 'key': 'eps', 'width': 8, 'format': self.fmt_num},
{'header': 'BVPS', 'key': 'bvps', 'width': 9, 'format': self.fmt_num},
{'header': 'RevGr%', 'key': 'rev_gr', 'width': 8, 'format': self.fmt_num},
]
self._add_table(ws, row, 0, ratio_data, ratio_cols, 'RatiosHistory')
# Conditional formatting on ROE
if ratio_data:
data_end = row + len(ratio_data)
ws.conditional_format(row + 1, 9, data_end, 9, {
'type': 'data_bar',
'bar_color': COLORS['success'],
'bar_solid': True,
})
ws.freeze_panes(4, 2)
def create_sentiment_history_sheet(self, data: list):
"""Create sentiment history sheet."""
ws = self.wb.add_worksheet("Sentiment History")
ws.merge_range('A1:K1', "Sentiment History", self.fmt_title)
ws.set_row(0, 25)
row = 3
sent_data = [{
'ticker': s['ticker'],
'name': s['name'],
'date': s.get('scrape_date', ''),
'time_range': s.get('time_range', ''),
'bullish': safe_float(s.get('bullish_pct')),
'bearish': safe_float(s.get('bearish_pct')),
'neutral': safe_float(s.get('neutral_pct')),
'bull_count': safe_float(s.get('bullish')),
'bear_count': safe_float(s.get('bearish')),
'neut_count': safe_float(s.get('neutral')),
'net': (safe_float(s.get('bullish_pct'), 0) - safe_float(s.get('bearish_pct'), 0)),
} for s in data]
sent_cols = [
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
{'header': 'Name', 'key': 'name', 'width': 22},
{'header': 'Date', 'key': 'date', 'width': 11},
{'header': 'Period', 'key': 'time_range', 'width': 15},
{'header': 'Bull%', 'key': 'bullish', 'width': 8, 'format': self.fmt_num},
{'header': 'Bear%', 'key': 'bearish', 'width': 8, 'format': self.fmt_num},
{'header': 'Neut%', 'key': 'neutral', 'width': 8, 'format': self.fmt_num},
{'header': 'Bulls', 'key': 'bull_count', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Bears', 'key': 'bear_count', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Neutral', 'key': 'neut_count', 'width': 8, 'format': self.fmt_num_0},
{'header': 'Net', 'key': 'net', 'width': 8, 'format': self.fmt_num},
]
self._add_table(ws, row, 0, sent_data, sent_cols, 'SentimentHistory')
# Conditional formatting
if sent_data:
data_end = row + len(sent_data)
# Bullish data bar
ws.conditional_format(row + 1, 4, data_end, 4, {
'type': 'data_bar',
'bar_color': COLORS['success'],
'bar_solid': True,
})
# Bearish data bar
ws.conditional_format(row + 1, 5, data_end, 5, {
'type': 'data_bar',
'bar_color': COLORS['danger'],
'bar_solid': True,
})
# Net sentiment 3-color
ws.conditional_format(row + 1, 10, data_end, 10, {
'type': '3_color_scale',
'min_color': '#F8696B',
'mid_color': '#FFEB84',
'max_color': '#63BE7B',
})
ws.freeze_panes(4, 2)
def create_price_pivot_sheet(self, stocks: list, price_history: list):
"""Create price pivot table (dates as rows, stocks as columns)."""
ws = self.wb.add_worksheet("Price Pivot")
ws.merge_range('A1:C1', "Price Matrix (Pivot)", self.fmt_title)
ws.set_row(0, 25)
if not price_history or not stocks:
ws.write(3, 0, "No data available")
return
# Get unique dates (limit to 365)
dates = sorted(set(p['scrape_date'] for p in price_history if p.get('scrape_date')),
reverse=True)[:365]
if not dates:
ws.write(3, 0, "No date data available")
return
# Limit to 100 stocks
stock_list = stocks[:100]
# Build price lookup
price_lookup = {}
for p in price_history:
key = (p['stock_id'], p['scrape_date'])
price_lookup[key] = p.get('price')
# Header row with tickers
row = 3
ws.write(row, 0, "Date", self.fmt_header)
for col, stock in enumerate(stock_list, 1):
ws.write(row, col, stock['ticker'], self.fmt_header)
# Data rows
for row_idx, date in enumerate(dates):
ws.write(row + 1 + row_idx, 0, date)
for col_idx, stock in enumerate(stock_list, 1):
price = price_lookup.get((stock['id'], date))
if price:
ws.write(row + 1 + row_idx, col_idx, price, self.fmt_num)
ws.freeze_panes(4, 1)
ws.set_column(0, 0, 12)
ws.set_column(1, 100, 10)
def generate(self):
"""Generate all history sheets."""
print("Loading data...")
stocks = self._get_stocks()
price_history = self._get_price_history()
ratios_history = self._get_ratios_history()
sentiment_history = self._get_sentiment_history()
scrape_runs = self._get_scrape_runs()
print("Creating Summary sheet...")
self.create_summary_sheet(stocks, price_history, ratios_history,
sentiment_history, scrape_runs)
print("Creating Price History sheet...")
self.create_price_history_sheet(price_history)
print("Creating Ratios History sheet...")
self.create_ratios_history_sheet(ratios_history)
print("Creating Sentiment History sheet...")
self.create_sentiment_history_sheet(sentiment_history)
if stocks and price_history:
print("Creating Price Pivot sheet...")
self.create_price_pivot_sheet(stocks, price_history)
def main():
parser = argparse.ArgumentParser(
description="Export historical stock data to professional Excel"
)
parser.add_argument('--db', required=True, help='Path to SQLite database')
parser.add_argument('--output', '-o', required=True, help='Output Excel file path')
parser.add_argument('--start-date', help='Start date filter (YYYY-MM-DD)')
parser.add_argument('--end-date', help='End date filter (YYYY-MM-DD)')
parser.add_argument('--stock', help='Filter by stock ID')
args = parser.parse_args()
if not Path(args.db).exists():
print(f"Error: Database not found: {args.db}")
return 1
conn = sqlite3.connect(args.db)
conn.row_factory = sqlite3.Row
workbook = xlsxwriter.Workbook(args.output, {
'constant_memory': False,
'strings_to_urls': True,
})
exporter = HistoryExporter(
workbook, conn,
start_date=args.start_date,
end_date=args.end_date,
stock_id=args.stock
)
exporter.generate()
workbook.close()
conn.close()
print(f"\nHistory exported to: {args.output}")
return 0
if __name__ == "__main__":
exit(main())

81
scripts/export_simple.py Normal file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Simple table export from SQLite to JSON/CSV/XLSX."""
import argparse
import json
import sqlite3
from pathlib import Path
import pandas as pd
DEFAULT_TABLES = [
"stocks",
"price_history",
"ratios_history",
"news",
"sentiment_history",
"scrape_runs",
"scrape_progress",
]
def load_table(conn: sqlite3.Connection, table: str) -> pd.DataFrame:
return pd.read_sql_query(f"SELECT * FROM {table}", conn)
def export_json(conn: sqlite3.Connection, outdir: Path, tables: list[str]) -> int:
outdir.mkdir(parents=True, exist_ok=True)
for table in tables:
df = load_table(conn, table)
data = json.loads(df.to_json(orient="records", date_format="iso"))
(outdir / f"{table}.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
return 0
def export_csv(conn: sqlite3.Connection, outdir: Path, tables: list[str]) -> int:
outdir.mkdir(parents=True, exist_ok=True)
for table in tables:
df = load_table(conn, table)
df.to_csv(outdir / f"{table}.csv", index=False)
return 0
def export_xlsx(conn: sqlite3.Connection, outfile: Path, tables: list[str]) -> int:
outfile.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(outfile, engine="openpyxl") as writer:
for table in tables:
df = load_table(conn, table)
sheet = table[:31] if table else "sheet"
df.to_excel(writer, sheet_name=sheet, index=False)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Simple SQLite table exporter")
parser.add_argument("--db", required=True, help="SQLite database path")
parser.add_argument("--format", required=True, choices=["json", "csv", "xlsx"], help="Export format")
parser.add_argument("--output", "-o", required=True, help="Output path (dir for json/csv, file for xlsx)")
parser.add_argument("--tables", help="Comma-separated tables (default: common tables)")
args = parser.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"Error: Database not found: {db_path}")
return 1
tables = [t.strip() for t in args.tables.split(",")] if args.tables else DEFAULT_TABLES
tables = [t for t in tables if t]
conn = sqlite3.connect(db_path)
try:
if args.format == "json":
return export_json(conn, Path(args.output), tables)
if args.format == "csv":
return export_csv(conn, Path(args.output), tables)
return export_xlsx(conn, Path(args.output), tables)
finally:
conn.close()
if __name__ == "__main__":
raise SystemExit(main())

65
scripts/release_bundle.sh Executable file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-}"
if [[ -z "$VERSION" ]]; then
if git describe --tags --always >/dev/null 2>&1; then
VERSION="$(git describe --tags --always)"
else
VERSION="0.0.0-$(date +%Y%m%d%H%M%S)"
fi
fi
APP_NAME="rubick"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
esac
DIST_DIR="dist"
PKG_BASENAME="${APP_NAME}_${VERSION}_${OS}_${ARCH}"
PKG_DIR="${DIST_DIR}/${PKG_BASENAME}"
rm -rf "$PKG_DIR"
mkdir -p "$PKG_DIR/bin" "$PKG_DIR/scripts"
# Build binary
GOOS="$OS" GOARCH="$ARCH" go build -o "$PKG_DIR/bin/$APP_NAME" ./cmd/rubick
# Runtime Python assets
cp extractor.py "$PKG_DIR/"
cp scripts/export_dashboard.py scripts/export_history.py scripts/export_simple.py "$PKG_DIR/scripts/"
cp pyproject.toml uv.lock .env.example README.md "$PKG_DIR/"
cat > "$PKG_DIR/INSTALL.md" <<'DOC'
# Rubick Bundle Install
1. Ensure Python 3.12+ and uv are installed.
2. In this bundle directory, run:
```bash
uv sync --frozen
```
3. Run the binary:
```bash
./bin/rubick --help
```
4. For live news queries, create `.env` with `BRAVE_API_KEY`.
DOC
# Checksums
( cd "$PKG_DIR" && shasum -a 256 bin/$APP_NAME extractor.py scripts/*.py pyproject.toml uv.lock > SHA256SUMS )
# Archives
( cd "$DIST_DIR" && tar -czf "${PKG_BASENAME}.tar.gz" "$PKG_BASENAME" )
( cd "$DIST_DIR" && zip -qr "${PKG_BASENAME}.zip" "$PKG_BASENAME" )
echo "release_bundle=$PKG_DIR"
echo "archive_tar=${DIST_DIR}/${PKG_BASENAME}.tar.gz"
echo "archive_zip=${DIST_DIR}/${PKG_BASENAME}.zip"

141
tests/go/cli_test.go Normal file
View file

@ -0,0 +1,141 @@
package gotests
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var (
repoRootPath string
testBinPath string
)
func repoRoot() string {
return repoRootPath
}
func TestMain(m *testing.M) {
root, err := filepath.Abs("../..")
if err != nil {
fmt.Fprintf(os.Stderr, "resolve repo root: %v\n", err)
os.Exit(1)
}
repoRootPath = root
binDir := filepath.Join(root, ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "create .bin dir: %v\n", err)
os.Exit(1)
}
testBinPath = filepath.Join(binDir, "rubick-test")
build := exec.Command("go", "build", "-o", testBinPath, "./cmd/rubick")
build.Dir = root
build.Stdout = os.Stdout
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
fmt.Fprintf(os.Stderr, "build test binary: %v\n", err)
os.Exit(1)
}
code := m.Run()
_ = os.Remove(testBinPath)
os.Exit(code)
}
func runCLI(t *testing.T, args ...string) (int, string) {
t.Helper()
cmd := exec.Command(testBinPath, args...)
cmd.Dir = repoRoot()
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
if err == nil {
return 0, buf.String()
}
if ee, ok := err.(*exec.ExitError); ok {
return ee.ExitCode(), buf.String()
}
t.Fatalf("failed to run command %v: %v", args, err)
return -1, ""
}
func runCLILive(t *testing.T, env map[string]string, args ...string) (int, string) {
t.Helper()
cmd := exec.Command(testBinPath, args...)
cmd.Dir = repoRoot()
cmd.Env = os.Environ()
for k, v := range env {
cmd.Env = append(cmd.Env, k+"="+v)
}
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
if err == nil {
return 0, buf.String()
}
if ee, ok := err.(*exec.ExitError); ok {
return ee.ExitCode(), buf.String()
}
t.Fatalf("failed to run command %v: %v", args, err)
return -1, ""
}
func TestCLIHelpExitCodes(t *testing.T) {
cases := [][]string{
{"--help"},
{"msn", "--help"},
{"msn", "screener", "--help"},
{"msn", "fetch", "--help"},
{"msn", "fetch-all", "--help"},
{"msn", "lookup", "--help"},
{"news", "--help"},
{"export", "--help"},
{"extractor", "--help"},
}
for _, c := range cases {
code, out := runCLI(t, c...)
if code != 0 {
t.Fatalf("expected exit 0 for %v, got %d\n%s", c, code, out)
}
}
}
func TestCLIErrorExitCodes(t *testing.T) {
cases := [][]string{
{"unknown"},
{"msn"},
{"news"},
{"export"},
{"extractor"},
}
for _, c := range cases {
code, _ := runCLI(t, c...)
if code == 0 {
t.Fatalf("expected non-zero exit for %v", c)
}
}
}
func TestCLILookup(t *testing.T) {
code, out := runCLI(t, "msn", "lookup", "BBCA")
if code != 0 {
t.Fatalf("expected success, got %d\n%s", code, out)
}
if !strings.Contains(out, "BBCA") {
t.Fatalf("expected output to contain BBCA, got:\n%s", out)
}
}

67
tests/go/live_e2e_test.go Normal file
View file

@ -0,0 +1,67 @@
package gotests
import (
"os"
"path/filepath"
"strings"
"testing"
)
func isTransientNetworkErr(out string) bool {
s := strings.ToLower(out)
patterns := []string{
"no such host", "timeout", "tempor", "connection reset", "connection refused", "429",
}
for _, p := range patterns {
if strings.Contains(s, p) {
return true
}
}
return false
}
func TestLiveMSNScreener(t *testing.T) {
if os.Getenv("RUN_LIVE_E2E") != "1" {
t.Skip("set RUN_LIVE_E2E=1 to run live tests")
}
outFile := filepath.Join(repoRoot(), "output", "live_test_screener.json")
code, out := runCLILive(t, nil,
"msn", "screener",
"--region", "id",
"--filter", "large-cap",
"--limit", "1",
"--output", outFile,
)
if code != 0 {
if isTransientNetworkErr(out) {
t.Skipf("transient/live network issue: %s", out)
}
t.Fatalf("live screener failed: %s", out)
}
}
func TestLiveNewsQuery(t *testing.T) {
if os.Getenv("RUN_LIVE_E2E") != "1" {
t.Skip("set RUN_LIVE_E2E=1 to run live tests")
}
if os.Getenv("BRAVE_API_KEY") == "" {
t.Skip("BRAVE_API_KEY not set")
}
outFile := filepath.Join(repoRoot(), "output", "live_test_news.json")
code, out := runCLILive(t, nil,
"news", "IHSG",
"--from", "2026-03-04",
"--to", "2026-03-06",
"--count", "1",
"--concurrency", "1",
"--output", outFile,
)
if code != 0 {
if isTransientNetworkErr(out) {
t.Skipf("transient/live network issue: %s", out)
}
t.Fatalf("live news failed: %s", out)
}
}

View file

@ -0,0 +1,49 @@
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
from scripts.export_simple import export_csv, export_json, export_xlsx
class ExportSimpleTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = Path(self.tmp.name)
self.db = self.base / "test.db"
conn = sqlite3.connect(self.db)
conn.execute("CREATE TABLE stocks (id TEXT, ticker TEXT)")
conn.execute("INSERT INTO stocks VALUES ('1','BBCA')")
conn.commit()
conn.close()
def tearDown(self):
self.tmp.cleanup()
def test_export_json(self):
conn = sqlite3.connect(self.db)
out = self.base / "json"
export_json(conn, out, ["stocks"])
conn.close()
data = json.loads((out / "stocks.json").read_text())
self.assertEqual(data[0]["ticker"], "BBCA")
def test_export_csv(self):
conn = sqlite3.connect(self.db)
out = self.base / "csv"
export_csv(conn, out, ["stocks"])
conn.close()
text = (out / "stocks.csv").read_text()
self.assertIn("BBCA", text)
def test_export_xlsx(self):
conn = sqlite3.connect(self.db)
out = self.base / "out.xlsx"
export_xlsx(conn, out, ["stocks"])
conn.close()
self.assertTrue(out.exists())
if __name__ == "__main__":
unittest.main()

806
uv.lock generated Normal file
View file

@ -0,0 +1,806 @@
version = 1
revision = 3
requires-python = ">=3.12"
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.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'",
]
[[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/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 = "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 = "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/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 = "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 = "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 = "filelock"
version = "3.21.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/71/74364ff065ca78914d8bd90b312fe78ddc5e11372d38bc9cb7104f887ce1/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708", size = 31486, upload-time = "2026-02-13T01:27:15.223Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/73/3a18f1e1276810e81477c431009b55eeccebbd7301d28a350b77aacf3c33/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560", size = 21479, upload-time = "2026-02-13T01:27:13.611Z" },
]
[[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 = "joblib"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
]
[package.optional-dependencies]
html-clean = [
{ name = "lxml-html-clean" },
]
[[package]]
name = "lxml-html-clean"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/cb/c9c5bb2a9c47292e236a808dd233a03531f53b626f36259dcd32b49c76da/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c", size = 21498, upload-time = "2025-10-02T20:49:24.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/4a/63a9540e3ca73709f4200564a737d63a4c8c9c4dd032bab8535f507c190a/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e", size = 14177, upload-time = "2025-10-02T20:49:23.749Z" },
]
[[package]]
name = "newspaper4k"
version = "0.9.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "brotli" },
{ name = "feedparser" },
{ name = "lxml", extra = ["html-clean"] },
{ name = "nltk" },
{ name = "pillow" },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "tldextract" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/cc/cf743a3d06b10907cec76a675fe0857445907ded0e64ec4624483c1467ce/newspaper4k-0.9.4.1.tar.gz", hash = "sha256:5b1a92dfb04d6d379f9484fad4ad44741deb9ac3d55a6c178badf2a0d4bba903", size = 3971874, upload-time = "2025-11-18T06:08:56.543Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/e8/7e6c6a6e626fec2ec25f7b69fa0c446d1ca8a7fad121b61f2672e5779b76/newspaper4k-0.9.4.1-py3-none-any.whl", hash = "sha256:fab18fdb0637da0ea2452e18c5986c4af2263ba3016ff684f1c522f696bd39bd", size = 306153, upload-time = "2025-11-18T06:08:54.9Z" },
]
[[package]]
name = "nltk"
version = "3.9.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "joblib" },
{ name = "regex" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" },
]
[[package]]
name = "numpy"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
{ url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
{ url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
{ url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
{ url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
{ url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
{ url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
{ url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
{ url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
{ url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
{ url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
{ url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
{ url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
{ url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
]
[[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 = "pandas"
version = "3.0.0"
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/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/38/db33686f4b5fa64d7af40d96361f6a4615b8c6c8f1b3d334eee46ae6160e/pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", size = 10334013, upload-time = "2026-01-21T15:50:34.771Z" },
{ url = "https://files.pythonhosted.org/packages/a5/7b/9254310594e9774906bacdd4e732415e1f86ab7dbb4b377ef9ede58cd8ec/pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", size = 9874154, upload-time = "2026-01-21T15:50:36.67Z" },
{ url = "https://files.pythonhosted.org/packages/63/d4/726c5a67a13bc66643e66d2e9ff115cead482a44fc56991d0c4014f15aaf/pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", size = 10384433, upload-time = "2026-01-21T15:50:39.132Z" },
{ url = "https://files.pythonhosted.org/packages/bf/2e/9211f09bedb04f9832122942de8b051804b31a39cfbad199a819bb88d9f3/pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", size = 10864519, upload-time = "2026-01-21T15:50:41.043Z" },
{ url = "https://files.pythonhosted.org/packages/00/8d/50858522cdc46ac88b9afdc3015e298959a70a08cd21e008a44e9520180c/pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", size = 11394124, upload-time = "2026-01-21T15:50:43.377Z" },
{ url = "https://files.pythonhosted.org/packages/86/3f/83b2577db02503cd93d8e95b0f794ad9d4be0ba7cb6c8bcdcac964a34a42/pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", size = 11920444, upload-time = "2026-01-21T15:50:45.932Z" },
{ url = "https://files.pythonhosted.org/packages/64/2d/4f8a2f192ed12c90a0aab47f5557ece0e56b0370c49de9454a09de7381b2/pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", size = 9730970, upload-time = "2026-01-21T15:50:47.962Z" },
{ url = "https://files.pythonhosted.org/packages/d4/64/ff571be435cf1e643ca98d0945d76732c0b4e9c37191a89c8550b105eed1/pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", size = 9041950, upload-time = "2026-01-21T15:50:50.422Z" },
{ url = "https://files.pythonhosted.org/packages/6f/fa/7f0ac4ca8877c57537aaff2a842f8760e630d8e824b730eb2e859ffe96ca/pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", size = 10307129, upload-time = "2026-01-21T15:50:52.877Z" },
{ url = "https://files.pythonhosted.org/packages/6f/11/28a221815dcea4c0c9414dfc845e34a84a6a7dabc6da3194498ed5ba4361/pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", size = 9850201, upload-time = "2026-01-21T15:50:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/ba/da/53bbc8c5363b7e5bd10f9ae59ab250fc7a382ea6ba08e4d06d8694370354/pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", size = 10354031, upload-time = "2026-01-21T15:50:57.463Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a3/51e02ebc2a14974170d51e2410dfdab58870ea9bcd37cda15bd553d24dc4/pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", size = 10861165, upload-time = "2026-01-21T15:50:59.32Z" },
{ url = "https://files.pythonhosted.org/packages/a5/fe/05a51e3cac11d161472b8297bd41723ea98013384dd6d76d115ce3482f9b/pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", size = 11359359, upload-time = "2026-01-21T15:51:02.014Z" },
{ url = "https://files.pythonhosted.org/packages/ee/56/ba620583225f9b85a4d3e69c01df3e3870659cc525f67929b60e9f21dcd1/pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", size = 11912907, upload-time = "2026-01-21T15:51:05.175Z" },
{ url = "https://files.pythonhosted.org/packages/c9/8c/c6638d9f67e45e07656b3826405c5cc5f57f6fd07c8b2572ade328c86e22/pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", size = 9732138, upload-time = "2026-01-21T15:51:07.569Z" },
{ url = "https://files.pythonhosted.org/packages/7b/bf/bd1335c3bf1770b6d8fed2799993b11c4971af93bb1b729b9ebbc02ca2ec/pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", size = 9033568, upload-time = "2026-01-21T15:51:09.484Z" },
{ url = "https://files.pythonhosted.org/packages/8e/c6/f5e2171914d5e29b9171d495344097d54e3ffe41d2d85d8115baba4dc483/pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", size = 10741936, upload-time = "2026-01-21T15:51:11.693Z" },
{ url = "https://files.pythonhosted.org/packages/51/88/9a0164f99510a1acb9f548691f022c756c2314aad0d8330a24616c14c462/pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", size = 10393884, upload-time = "2026-01-21T15:51:14.197Z" },
{ url = "https://files.pythonhosted.org/packages/e0/53/b34d78084d88d8ae2b848591229da8826d1e65aacf00b3abe34023467648/pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", size = 10310740, upload-time = "2026-01-21T15:51:16.093Z" },
{ url = "https://files.pythonhosted.org/packages/5b/d3/bee792e7c3d6930b74468d990604325701412e55d7aaf47460a22311d1a5/pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", size = 10700014, upload-time = "2026-01-21T15:51:18.818Z" },
{ url = "https://files.pythonhosted.org/packages/55/db/2570bc40fb13aaed1cbc3fbd725c3a60ee162477982123c3adc8971e7ac1/pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", size = 11323737, upload-time = "2026-01-21T15:51:20.784Z" },
{ url = "https://files.pythonhosted.org/packages/bc/2e/297ac7f21c8181b62a4cccebad0a70caf679adf3ae5e83cb676194c8acc3/pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", size = 11771558, upload-time = "2026-01-21T15:51:22.977Z" },
{ url = "https://files.pythonhosted.org/packages/0a/46/e1c6876d71c14332be70239acce9ad435975a80541086e5ffba2f249bcf6/pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", size = 10473771, upload-time = "2026-01-21T15:51:25.285Z" },
{ url = "https://files.pythonhosted.org/packages/c0/db/0270ad9d13c344b7a36fa77f5f8344a46501abf413803e885d22864d10bf/pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", size = 10312075, upload-time = "2026-01-21T15:51:28.5Z" },
{ url = "https://files.pythonhosted.org/packages/09/9f/c176f5e9717f7c91becfe0f55a52ae445d3f7326b4a2cf355978c51b7913/pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", size = 9900213, upload-time = "2026-01-21T15:51:30.955Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e7/63ad4cc10b257b143e0a5ebb04304ad806b4e1a61c5da25f55896d2ca0f4/pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", size = 10428768, upload-time = "2026-01-21T15:51:33.018Z" },
{ url = "https://files.pythonhosted.org/packages/9e/0e/4e4c2d8210f20149fd2248ef3fff26623604922bd564d915f935a06dd63d/pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d", size = 10882954, upload-time = "2026-01-21T15:51:35.287Z" },
{ url = "https://files.pythonhosted.org/packages/c6/60/c9de8ac906ba1f4d2250f8a951abe5135b404227a55858a75ad26f84db47/pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", size = 11430293, upload-time = "2026-01-21T15:51:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/a1/69/806e6637c70920e5787a6d6896fd707f8134c2c55cd761e7249a97b7dc5a/pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", size = 11952452, upload-time = "2026-01-21T15:51:39.618Z" },
{ url = "https://files.pythonhosted.org/packages/cb/de/918621e46af55164c400ab0ef389c9d969ab85a43d59ad1207d4ddbe30a5/pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", size = 9851081, upload-time = "2026-01-21T15:51:41.758Z" },
{ url = "https://files.pythonhosted.org/packages/91/a1/3562a18dd0bd8c73344bfa26ff90c53c72f827df119d6d6b1dacc84d13e3/pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", size = 9174610, upload-time = "2026-01-21T15:51:44.312Z" },
{ url = "https://files.pythonhosted.org/packages/ce/26/430d91257eaf366f1737d7a1c158677caaf6267f338ec74e3a1ec444111c/pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", size = 10761999, upload-time = "2026-01-21T15:51:46.899Z" },
{ url = "https://files.pythonhosted.org/packages/ec/1a/954eb47736c2b7f7fe6a9d56b0cb6987773c00faa3c6451a43db4beb3254/pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", size = 10410279, upload-time = "2026-01-21T15:51:48.89Z" },
{ url = "https://files.pythonhosted.org/packages/20/fc/b96f3a5a28b250cd1b366eb0108df2501c0f38314a00847242abab71bb3a/pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", size = 10330198, upload-time = "2026-01-21T15:51:51.015Z" },
{ url = "https://files.pythonhosted.org/packages/90/b3/d0e2952f103b4fbef1ef22d0c2e314e74fc9064b51cee30890b5e3286ee6/pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", size = 10728513, upload-time = "2026-01-21T15:51:53.387Z" },
{ url = "https://files.pythonhosted.org/packages/76/81/832894f286df828993dc5fd61c63b231b0fb73377e99f6c6c369174cf97e/pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", size = 11345550, upload-time = "2026-01-21T15:51:55.329Z" },
{ url = "https://files.pythonhosted.org/packages/34/a0/ed160a00fb4f37d806406bc0a79a8b62fe67f29d00950f8d16203ff3409b/pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", size = 11799386, upload-time = "2026-01-21T15:51:57.457Z" },
{ url = "https://files.pythonhosted.org/packages/36/c8/2ac00d7255252c5e3cf61b35ca92ca25704b0188f7454ca4aec08a33cece/pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", size = 10873041, upload-time = "2026-01-21T15:52:00.034Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003, upload-time = "2026-01-21T15:52:02.281Z" },
]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[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 = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "regex"
version = "2026.1.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" },
{ url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" },
{ url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" },
{ url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" },
{ url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" },
{ url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" },
{ url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" },
{ url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" },
{ url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" },
{ url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" },
{ url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" },
{ url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
{ url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
{ url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
{ url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
{ url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
{ url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
{ url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
{ url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
{ url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
{ url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
{ url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
{ url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
{ url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
{ url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
{ url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
{ url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
{ url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
{ url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
{ url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
{ url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
{ url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
{ url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
{ url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
{ url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
{ url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
{ url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
{ url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
{ url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
{ url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
{ url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
{ url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
{ url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
{ url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
{ url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
{ url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
{ url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
{ url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
{ url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
{ url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
{ url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
{ url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
{ url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
{ url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
{ url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
{ url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
{ url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
{ url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
{ url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
{ url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
{ url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "requests-file"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" },
]
[[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 = "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 = "rubick"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "lxml-html-clean" },
{ name = "newspaper4k" },
{ name = "openpyxl" },
{ name = "pandas" },
{ name = "xlsxwriter" },
]
[package.metadata]
requires-dist = [
{ name = "lxml-html-clean", specifier = ">=0.4.3" },
{ name = "newspaper4k", specifier = ">=0.9.4.1" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pandas", specifier = ">=3.0.0" },
{ name = "xlsxwriter", specifier = ">=3.2.9" },
]
[[package]]
name = "tldextract"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "idna" },
{ name = "requests" },
{ name = "requests-file" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/7b/644fbbb49564a6cb124a8582013315a41148dba2f72209bba14a84242bf0/tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb", size = 126105, upload-time = "2025-12-28T23:58:05.532Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/42/0e49d6d0aac449ca71952ec5bae764af009754fcb2e76a5cc097543747b3/tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9", size = 105886, upload-time = "2025-12-28T23:58:04.071Z" },
]
[[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 = "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 = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[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 = "xlsxwriter"
version = "3.2.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
]