From fab6cf26eb7c064a1162b195155e68d2b4852e90 Mon Sep 17 00:00:00 2001 From: Muhammad Firas Date: Fri, 6 Mar 2026 03:12:28 +0700 Subject: [PATCH] feat(rubick): rename project and add production release bundle --- .env.example | 1 + .gitignore | 41 + Makefile | 49 + README.md | 784 +++++++++++++++ TEST_REPORT.md | 87 ++ extractor.py | 165 ++++ go.mod | 27 + go.sum | 48 + internal/cli/brave.go | 129 +++ internal/cli/extractor.go | 282 ++++++ internal/cli/main.go | 143 +++ internal/cli/msn_cli.go | 904 +++++++++++++++++ internal/cli/news_cli.go | 336 +++++++ main.go | 11 + msn/bing_client.go | 324 ++++++ msn/db.go | 1054 ++++++++++++++++++++ msn/idx_stocks.go | 1844 +++++++++++++++++++++++++++++++++++ msn/msn_client.go | 652 +++++++++++++ msn/msn_screener.go | 228 +++++ msn/msn_stock.go | 353 +++++++ msn/msn_types.go | 550 +++++++++++ msn/news_analysis.go | 194 ++++ msn/rate_limiter.go | 97 ++ pyproject.toml | 12 + scripts/e2e_run.sh | 31 + scripts/export_dashboard.py | 1373 ++++++++++++++++++++++++++ scripts/export_history.py | 626 ++++++++++++ scripts/export_simple.py | 81 ++ scripts/release_bundle.sh | 65 ++ tests/go/cli_test.go | 141 +++ tests/go/live_e2e_test.go | 67 ++ tests/test_export_simple.py | 49 + uv.lock | 806 +++++++++++++++ 33 files changed, 11554 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 TEST_REPORT.md create mode 100644 extractor.py create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cli/brave.go create mode 100644 internal/cli/extractor.go create mode 100644 internal/cli/main.go create mode 100644 internal/cli/msn_cli.go create mode 100644 internal/cli/news_cli.go create mode 100644 main.go create mode 100644 msn/bing_client.go create mode 100644 msn/db.go create mode 100644 msn/idx_stocks.go create mode 100644 msn/msn_client.go create mode 100644 msn/msn_screener.go create mode 100644 msn/msn_stock.go create mode 100644 msn/msn_types.go create mode 100644 msn/news_analysis.go create mode 100644 msn/rate_limiter.go create mode 100644 pyproject.toml create mode 100755 scripts/e2e_run.sh create mode 100644 scripts/export_dashboard.py create mode 100644 scripts/export_history.py create mode 100644 scripts/export_simple.py create mode 100755 scripts/release_bundle.sh create mode 100644 tests/go/cli_test.go create mode 100644 tests/go/live_e2e_test.go create mode 100644 tests/test_export_simple.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c50aee --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +BRAVE_API_KEY=your-brave-api-key-here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99c4e08 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b6f21c --- /dev/null +++ b/Makefile @@ -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/" + @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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..aefbb49 --- /dev/null +++ b/README.md @@ -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
Go] + + C --> M[MSN Workflows
Go] + C --> N[News Workflow
Go] + C --> E[Export Workflow
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
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 [options] +``` + +Developer-only shortcut (not recommended for operational runbooks): + +```bash +go run ./cmd/rubick [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 `` | 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___/` +- `dist/rubick___.tar.gz` +- `dist/rubick___.zip` + +Bundle runtime setup: + +```bash +cd dist/rubick___ +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. diff --git a/TEST_REPORT.md b/TEST_REPORT.md new file mode 100644 index 0000000..a770566 --- /dev/null +++ b/TEST_REPORT.md @@ -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. diff --git a/extractor.py b/extractor.py new file mode 100644 index 0000000..8e34afa --- /dev/null +++ b/extractor.py @@ -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() diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df85055 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5ba08f7 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/cli/brave.go b/internal/cli/brave.go new file mode 100644 index 0000000..72a270f --- /dev/null +++ b/internal/cli/brave.go @@ -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 +} diff --git a/internal/cli/extractor.go b/internal/cli/extractor.go new file mode 100644 index 0000000..ae17292 --- /dev/null +++ b/internal/cli/extractor.go @@ -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 +} diff --git a/internal/cli/main.go b/internal/cli/main.go new file mode 100644 index 0000000..0aef196 --- /dev/null +++ b/internal/cli/main.go @@ -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 [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 [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 [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 +} diff --git a/internal/cli/msn_cli.go b/internal/cli/msn_cli.go new file mode 100644 index 0000000..0a8bed5 --- /dev/null +++ b/internal/cli/msn_cli.go @@ -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 [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 [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 Country code (default: id) + --filter top-performers|worst-performers|high-dividend|low-pe|52w-high|52w-low|high-volume|large-cap + --limit Max results (default: 50) + --output, -o Output JSON path +`) +} + +func printMSNFetchUsage() { + fmt.Fprintf(os.Stderr, `Usage: rubick msn fetch [options] + +Options: + --input Screener JSON input + --ids Comma-separated MSN IDs + --tickers Comma-separated ticker symbols + --concurrency Parallel workers (default: 5) + --output, -o Output JSON path +`) +} + +func printMSNFetchAllUsage() { + fmt.Fprintf(os.Stderr, `Usage: rubick msn fetch-all [options] + +Options: + --db SQLite database path (default: output/stocks.db) + --index all|lq45|idx30|idx80 (default: all) + --proxy Proxy URL (http://, https://, socks5://) + --concurrency Parallel workers (default: 5) + --rps Max requests/sec (default: 25) + --delay Random delay ms (default: 100-500) + --retry Retry attempts (default: 2) + --limit Process only N stocks + --resume Resume incomplete run +`) +} + +func printMSNLookupUsage() { + fmt.Fprintf(os.Stderr, `Usage: rubick msn lookup [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 +} diff --git a/internal/cli/news_cli.go b/internal/cli/news_cli.go new file mode 100644 index 0000000..b18782f --- /dev/null +++ b/internal/cli/news_cli.go @@ -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 [options] + +Arguments: + Search query (required) + For --stock mode: comma-separated stock terms + +Options: + --from Start date in YYYY-MM-DD format (default: 7 days ago) + --to End date in YYYY-MM-DD format (default: today) + --count Number of results to fetch (default: 20) + --concurrency Number of parallel workers (default: 10) + --output, -o 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] + "..." +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5763a3f --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "rubick/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:])) +} diff --git a/msn/bing_client.go b/msn/bing_client.go new file mode 100644 index 0000000..fe194b1 --- /dev/null +++ b/msn/bing_client.go @@ -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 +} diff --git a/msn/db.go b/msn/db.go new file mode 100644 index 0000000..0158da0 --- /dev/null +++ b/msn/db.go @@ -0,0 +1,1054 @@ +package msn + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// StockDB manages SQLite database operations +type StockDB struct { + db *sql.DB +} + +// NewStockDB creates a new database connection and initializes schema +func NewStockDB(dbPath string) (*StockDB, error) { + db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_synchronous=NORMAL") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + sdb := &StockDB{db: db} + if err := sdb.initSchema(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + + return sdb, nil +} + +// Close closes the database connection +func (s *StockDB) Close() error { + return s.db.Close() +} + +// initSchema creates all database tables +func (s *StockDB) initSchema() error { + schema := ` + -- Core stock info (updated each run) + CREATE TABLE IF NOT EXISTS stocks ( + id TEXT PRIMARY KEY, + ticker TEXT NOT NULL, + name TEXT, + display_name TEXT, + sector TEXT, + industry TEXT, + exchange_id TEXT, + exchange_code TEXT, + exchange_name TEXT, + country TEXT, + currency TEXT, + market TEXT, + website TEXT, + employees INTEGER, + description TEXT, + address TEXT, + city TEXT, + phone TEXT, + last_updated DATETIME, + last_seen DATE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_stocks_ticker ON stocks(ticker); + CREATE INDEX IF NOT EXISTS idx_stocks_sector ON stocks(sector); + CREATE INDEX IF NOT EXISTS idx_stocks_last_seen ON stocks(last_seen); + + -- Price snapshots (historical - one row per stock per day) + CREATE TABLE IF NOT EXISTS price_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + scrape_date DATE NOT NULL, + price REAL, + price_change REAL, + price_change_pct REAL, + price_open REAL, + price_high REAL, + price_low REAL, + price_close REAL, + price_prev_close REAL, + price_52w_high REAL, + price_52w_low REAL, + volume REAL, + avg_volume REAL, + market_cap REAL, + market_cap_currency TEXT, + price_change_1w REAL, + price_change_1m REAL, + price_change_3m REAL, + price_change_6m REAL, + price_change_ytd REAL, + price_change_1y REAL, + return_1w REAL, + return_1m REAL, + return_3m REAL, + return_6m REAL, + return_ytd REAL, + return_1y REAL, + time_last_traded TEXT, + UNIQUE(stock_id, scrape_date), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_price_history_stock ON price_history(stock_id); + CREATE INDEX IF NOT EXISTS idx_price_history_date ON price_history(scrape_date); + + -- Financial ratios (historical - per year per stock) + CREATE TABLE IF NOT EXISTS ratios_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + scrape_date DATE NOT NULL, + year TEXT, + fiscal_period TEXT, + pe_ratio REAL, + pb_ratio REAL, + ps_ratio REAL, + pcf_ratio REAL, + ev_ebitda REAL, + dividend_yield REAL, + payout_ratio REAL, + roe REAL, + roa REAL, + roic REAL, + gross_margin REAL, + operating_margin REAL, + net_margin REAL, + debt_to_equity REAL, + debt_to_ebitda REAL, + financial_leverage REAL, + current_ratio REAL, + quick_ratio REAL, + asset_turnover REAL, + inventory_turnover REAL, + receivable_turnover REAL, + revenue_growth REAL, + earnings_growth REAL, + eps REAL, + bvps REAL, + revenue_per_share REAL, + fcf_per_share REAL, + dividend_per_share REAL, + UNIQUE(stock_id, scrape_date, year), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_ratios_history_stock ON ratios_history(stock_id); + + -- Balance sheets + CREATE TABLE IF NOT EXISTS balance_sheets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + report_date DATE, + end_date DATE, + source TEXT, + source_date DATE, + current_assets_json TEXT, + long_term_assets_json TEXT, + current_liabilities_json TEXT, + equity_json TEXT, + currency TEXT, + scrape_date DATE NOT NULL, + UNIQUE(stock_id, end_date), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + + -- Cash flows + CREATE TABLE IF NOT EXISTS cash_flows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + report_date DATE, + end_date DATE, + source TEXT, + operating_json TEXT, + investing_json TEXT, + financing_json TEXT, + currency TEXT, + scrape_date DATE NOT NULL, + UNIQUE(stock_id, end_date), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + + -- Income statements + CREATE TABLE IF NOT EXISTS income_statements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + report_date DATE, + end_date DATE, + source TEXT, + revenue_json TEXT, + expenses_json TEXT, + currency TEXT, + scrape_date DATE NOT NULL, + UNIQUE(stock_id, end_date), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + + -- Earnings events + CREATE TABLE IF NOT EXISTS earnings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + event_date DATE, + fiscal_year INTEGER, + fiscal_quarter INTEGER, + eps_estimate REAL, + eps_actual REAL, + eps_surprise REAL, + eps_surprise_pct REAL, + revenue_estimate REAL, + revenue_actual REAL, + revenue_surprise REAL, + scrape_date DATE NOT NULL, + UNIQUE(stock_id, event_date, fiscal_year, fiscal_quarter), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_earnings_stock ON earnings(stock_id); + + -- Chart OHLCV data + CREATE TABLE IF NOT EXISTS charts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + timeframe TEXT NOT NULL, + timestamp DATETIME NOT NULL, + open REAL, + high REAL, + low REAL, + close REAL, + volume INTEGER, + scrape_date DATE NOT NULL, + UNIQUE(stock_id, timeframe, timestamp), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_charts_stock_tf ON charts(stock_id, timeframe); + + -- News articles + CREATE TABLE IF NOT EXISTS news ( + id TEXT PRIMARY KEY, + stock_id TEXT NOT NULL, + title TEXT, + url TEXT, + abstract TEXT, + source_id TEXT, + source_name TEXT, + published_at DATETIME, + read_time_min INTEGER, + image_url TEXT, + image_width INTEGER, + image_height INTEGER, + news_type TEXT, + category TEXT, + sentiment TEXT, + sentiment_score REAL, + is_critical INTEGER DEFAULT 0, + first_seen DATE NOT NULL, + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_news_stock ON news(stock_id); + CREATE INDEX IF NOT EXISTS idx_news_published ON news(published_at); + CREATE INDEX IF NOT EXISTS idx_news_category ON news(category); + CREATE INDEX IF NOT EXISTS idx_news_critical ON news(is_critical); + + -- Sentiment data (historical) + CREATE TABLE IF NOT EXISTS sentiment_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + scrape_date DATE NOT NULL, + time_range TEXT, + time_range_enum TEXT, + start_time INTEGER, + end_time INTEGER, + bullish INTEGER, + bearish INTEGER, + neutral INTEGER, + bullish_pct REAL, + bearish_pct REAL, + neutral_pct REAL, + scenario TEXT, + UNIQUE(stock_id, scrape_date, time_range_enum), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_sentiment_stock ON sentiment_history(stock_id); + + -- AI Insights + CREATE TABLE IF NOT EXISTS insights ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + scrape_date DATE NOT NULL, + summary TEXT, + highlights_json TEXT, + risks_json TEXT, + last_updated TEXT, + UNIQUE(stock_id, scrape_date), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + + -- Ownership data + CREATE TABLE IF NOT EXISTS ownership ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + scrape_date DATE NOT NULL, + holder_type TEXT, + investor_name TEXT, + investor_type TEXT, + shares_held INTEGER, + shares_change INTEGER, + shares_pct REAL, + value REAL, + report_date DATE, + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_ownership_stock ON ownership(stock_id); + + -- Company officers + CREATE TABLE IF NOT EXISTS officers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stock_id TEXT NOT NULL, + name TEXT, + title TEXT, + age INTEGER, + year_born INTEGER, + total_pay INTEGER, + as_of_date DATE, + scrape_date DATE NOT NULL, + UNIQUE(stock_id, name, title), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + + -- Scrape runs tracking + CREATE TABLE IF NOT EXISTS scrape_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at DATETIME NOT NULL, + completed_at DATETIME, + status TEXT NOT NULL DEFAULT 'running', + total_stocks INTEGER, + successful INTEGER DEFAULT 0, + failed INTEGER DEFAULT 0, + config_json TEXT + ); + + -- Progress tracking (for resume) + CREATE TABLE IF NOT EXISTS scrape_progress ( + run_id INTEGER NOT NULL, + stock_id TEXT NOT NULL, + ticker TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + apis_success INTEGER DEFAULT 0, + apis_failed INTEGER DEFAULT 0, + started_at DATETIME, + completed_at DATETIME, + PRIMARY KEY(run_id, stock_id), + FOREIGN KEY(run_id) REFERENCES scrape_runs(id) + ); + CREATE INDEX IF NOT EXISTS idx_progress_status ON scrape_progress(run_id, status); + + -- Stock indices (LQ45, IDX80, etc.) + CREATE TABLE IF NOT EXISTS stock_indices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + index_name TEXT NOT NULL, + stock_id TEXT NOT NULL, + ticker TEXT NOT NULL, + added_date DATE, + UNIQUE(index_name, stock_id), + FOREIGN KEY(stock_id) REFERENCES stocks(id) + ); + CREATE INDEX IF NOT EXISTS idx_stock_indices_name ON stock_indices(index_name); + ` + + _, err := s.db.Exec(schema) + return err +} + +// StartScrapeRun creates a new scrape run and returns its ID +func (s *StockDB) StartScrapeRun(totalStocks int, config map[string]interface{}) (int64, error) { + configJSON, _ := json.Marshal(config) + + result, err := s.db.Exec(` + INSERT INTO scrape_runs (started_at, status, total_stocks, config_json) + VALUES (?, 'running', ?, ?) + `, time.Now().UTC(), totalStocks, string(configJSON)) + + if err != nil { + return 0, err + } + + return result.LastInsertId() +} + +// InitProgress initializes progress for all stocks in a run +func (s *StockDB) InitProgress(runID int64, stocks map[string]IDXStock) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(` + INSERT OR IGNORE INTO scrape_progress (run_id, stock_id, ticker, status) + VALUES (?, ?, ?, 'pending') + `) + if err != nil { + return err + } + defer stmt.Close() + + for ticker, stock := range stocks { + _, err := stmt.Exec(runID, stock.ID, ticker) + if err != nil { + return err + } + } + + return tx.Commit() +} + +// GetPendingStocks returns stocks that haven't been processed in this run +func (s *StockDB) GetPendingStocks(runID int64) ([]struct { + ID string + Ticker string +}, error) { + rows, err := s.db.Query(` + SELECT stock_id, ticker FROM scrape_progress + WHERE run_id = ? AND status = 'pending' + ORDER BY ticker + `, runID) + if err != nil { + return nil, err + } + defer rows.Close() + + var stocks []struct { + ID string + Ticker string + } + + for rows.Next() { + var stock struct { + ID string + Ticker string + } + if err := rows.Scan(&stock.ID, &stock.Ticker); err != nil { + return nil, err + } + stocks = append(stocks, stock) + } + + return stocks, nil +} + +// UpdateProgress updates the progress of a stock +func (s *StockDB) UpdateProgress(runID int64, stockID string, status string, apisSuccess, apisFailed int, errMsg string) error { + _, err := s.db.Exec(` + UPDATE scrape_progress + SET status = ?, apis_success = ?, apis_failed = ?, error_message = ?, completed_at = ? + WHERE run_id = ? AND stock_id = ? + `, status, apisSuccess, apisFailed, errMsg, time.Now().UTC(), runID, stockID) + return err +} + +// MarkProgressStarted marks a stock as started +func (s *StockDB) MarkProgressStarted(runID int64, stockID string) error { + _, err := s.db.Exec(` + UPDATE scrape_progress SET status = 'processing', started_at = ? + WHERE run_id = ? AND stock_id = ? + `, time.Now().UTC(), runID, stockID) + return err +} + +// CompleteScrapeRun marks a scrape run as completed +func (s *StockDB) CompleteScrapeRun(runID int64, status string) error { + // Count successful and failed + var successful, failed int + s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'success'`, runID).Scan(&successful) + s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'failed'`, runID).Scan(&failed) + + _, err := s.db.Exec(` + UPDATE scrape_runs SET completed_at = ?, status = ?, successful = ?, failed = ? + WHERE id = ? + `, time.Now().UTC(), status, successful, failed, runID) + return err +} + +// GetLastRunID returns the most recent incomplete run ID (for resume) +func (s *StockDB) GetLastRunID() (int64, error) { + var runID int64 + err := s.db.QueryRow(` + SELECT id FROM scrape_runs + WHERE status IN ('running', 'interrupted') + ORDER BY started_at DESC LIMIT 1 + `).Scan(&runID) + + if err == sql.ErrNoRows { + return 0, nil + } + return runID, err +} + +// SaveStockData saves all stock data to the database +func (s *StockDB) SaveStockData(stock *StockData) error { + today := time.Now().UTC().Format("2006-01-02") + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Save core stock info + if err := s.saveStock(tx, stock, today); err != nil { + return fmt.Errorf("save stock: %w", err) + } + + // Save price history + if stock.Quote != nil { + if err := s.savePriceHistory(tx, stock, today); err != nil { + return fmt.Errorf("save price: %w", err) + } + } + + // Save ratios + if stock.KeyRatios != nil { + if err := s.saveRatios(tx, stock, today); err != nil { + return fmt.Errorf("save ratios: %w", err) + } + } + + // Save financials + if stock.Financials != nil { + if err := s.saveFinancials(tx, stock, today); err != nil { + return fmt.Errorf("save financials: %w", err) + } + } + + // Save earnings + if len(stock.Earnings) > 0 { + if err := s.saveEarnings(tx, stock, today); err != nil { + return fmt.Errorf("save earnings: %w", err) + } + } + + // Save charts + if len(stock.Charts) > 0 { + if err := s.saveCharts(tx, stock, today); err != nil { + return fmt.Errorf("save charts: %w", err) + } + } + + // Save news + if len(stock.News) > 0 { + if err := s.saveNews(tx, stock, today); err != nil { + return fmt.Errorf("save news: %w", err) + } + } + + // Save sentiment + if stock.Sentiment != nil { + if err := s.saveSentiment(tx, stock, today); err != nil { + return fmt.Errorf("save sentiment: %w", err) + } + } + + // Save insights + if stock.Insights != nil { + if err := s.saveInsights(tx, stock, today); err != nil { + return fmt.Errorf("save insights: %w", err) + } + } + + // Save ownership + if stock.Ownership != nil { + if err := s.saveOwnership(tx, stock, today); err != nil { + return fmt.Errorf("save ownership: %w", err) + } + } + + return tx.Commit() +} + +func (s *StockDB) saveStock(tx *sql.Tx, stock *StockData, today string) error { + var website, description, address, city, phone string + var employees int + + if stock.Company != nil { + website = stock.Company.Website + description = stock.Company.Description + address = stock.Company.Address + city = stock.Company.City + phone = stock.Company.Phone + employees = stock.Company.Employees + } + + var exchangeCode, exchangeName, country, currency, market, displayName string + if stock.Quote != nil { + exchangeCode = stock.Quote.ExchangeCode + exchangeName = stock.Quote.ExchangeName + country = stock.Quote.Country + currency = stock.Quote.Currency + market = stock.Quote.Market + displayName = stock.Quote.DisplayName + } + + _, err := tx.Exec(` + INSERT INTO stocks (id, ticker, name, display_name, sector, industry, + exchange_id, exchange_code, exchange_name, country, currency, market, + website, employees, description, address, city, phone, last_updated, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ticker = excluded.ticker, + name = excluded.name, + display_name = excluded.display_name, + sector = COALESCE(excluded.sector, sector), + industry = COALESCE(excluded.industry, industry), + exchange_id = COALESCE(excluded.exchange_id, exchange_id), + exchange_code = COALESCE(excluded.exchange_code, exchange_code), + exchange_name = COALESCE(excluded.exchange_name, exchange_name), + country = COALESCE(excluded.country, country), + currency = COALESCE(excluded.currency, currency), + market = COALESCE(excluded.market, market), + website = COALESCE(excluded.website, website), + employees = COALESCE(excluded.employees, employees), + description = COALESCE(excluded.description, description), + address = COALESCE(excluded.address, address), + city = COALESCE(excluded.city, city), + phone = COALESCE(excluded.phone, phone), + last_updated = excluded.last_updated, + last_seen = excluded.last_seen + `, stock.ID, stock.Ticker, stock.Name, displayName, stock.Sector, stock.Industry, + stock.Exchange, exchangeCode, exchangeName, country, currency, market, + website, employees, description, address, city, phone, stock.FetchedAt, today) + + return err +} + +func (s *StockDB) savePriceHistory(tx *sql.Tx, stock *StockData, today string) error { + q := stock.Quote + _, err := tx.Exec(` + INSERT INTO price_history (stock_id, scrape_date, price, price_change, price_change_pct, + price_open, price_high, price_low, price_close, price_prev_close, + price_52w_high, price_52w_low, volume, avg_volume, market_cap, market_cap_currency, + price_change_1w, price_change_1m, price_change_3m, price_change_6m, price_change_ytd, price_change_1y, + return_1w, return_1m, return_3m, return_6m, return_ytd, return_1y, time_last_traded) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, scrape_date) DO UPDATE SET + price = excluded.price, + price_change = excluded.price_change, + price_change_pct = excluded.price_change_pct, + price_open = excluded.price_open, + price_high = excluded.price_high, + price_low = excluded.price_low, + price_close = excluded.price_close, + price_prev_close = excluded.price_prev_close, + price_52w_high = excluded.price_52w_high, + price_52w_low = excluded.price_52w_low, + volume = excluded.volume, + avg_volume = excluded.avg_volume, + market_cap = excluded.market_cap, + market_cap_currency = excluded.market_cap_currency, + price_change_1w = excluded.price_change_1w, + price_change_1m = excluded.price_change_1m, + price_change_3m = excluded.price_change_3m, + price_change_6m = excluded.price_change_6m, + price_change_ytd = excluded.price_change_ytd, + price_change_1y = excluded.price_change_1y, + return_1w = excluded.return_1w, + return_1m = excluded.return_1m, + return_3m = excluded.return_3m, + return_6m = excluded.return_6m, + return_ytd = excluded.return_ytd, + return_1y = excluded.return_1y, + time_last_traded = excluded.time_last_traded + `, stock.ID, today, q.Price, q.PriceChange, q.PriceChangePct, + q.PriceDayOpen, q.PriceDayHigh, q.PriceDayLow, q.PriceClose, q.PricePreviousClose, + q.Price52wHigh, q.Price52wLow, q.AccumulatedVolume, q.AverageVolume, q.MarketCap, q.MarketCapCurrency, + q.PriceChange1Week, q.PriceChange1Month, q.PriceChange3Month, q.PriceChange6Month, q.PriceChangeYTD, q.PriceChange1Year, + q.Return1Week, q.Return1Month, q.Return3Month, q.Return6Month, q.ReturnYTD, q.Return1Year, q.TimeLastTraded) + + return err +} + +func (s *StockDB) saveRatios(tx *sql.Tx, stock *StockData, today string) error { + // Get current price for dividend yield calculation + var currentPrice float64 + if stock.Quote != nil && stock.Quote.Price > 0 { + currentPrice = stock.Quote.Price + } + + for _, metric := range stock.KeyRatios.IndustryMetrics { + // Calculate dividend yield: (DividendPerShare / Price) * 100 + var dividendYield float64 + if currentPrice > 0 && metric.DividendPerShare > 0 { + dividendYield = (metric.DividendPerShare / currentPrice) * 100 + } + + _, err := tx.Exec(` + INSERT INTO ratios_history (stock_id, scrape_date, year, fiscal_period, + pe_ratio, pb_ratio, ps_ratio, pcf_ratio, ev_ebitda, + dividend_yield, payout_ratio, roe, roa, roic, + gross_margin, operating_margin, net_margin, + debt_to_equity, debt_to_ebitda, financial_leverage, + current_ratio, quick_ratio, asset_turnover, inventory_turnover, receivable_turnover, + revenue_growth, earnings_growth, eps, bvps, revenue_per_share, fcf_per_share, dividend_per_share) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, scrape_date, year) DO UPDATE SET + fiscal_period = excluded.fiscal_period, + pe_ratio = excluded.pe_ratio, + pb_ratio = excluded.pb_ratio, + ps_ratio = excluded.ps_ratio, + pcf_ratio = excluded.pcf_ratio, + ev_ebitda = excluded.ev_ebitda, + dividend_yield = excluded.dividend_yield, + payout_ratio = excluded.payout_ratio, + roe = excluded.roe, + roa = excluded.roa, + roic = excluded.roic, + gross_margin = excluded.gross_margin, + operating_margin = excluded.operating_margin, + net_margin = excluded.net_margin, + debt_to_equity = excluded.debt_to_equity, + debt_to_ebitda = excluded.debt_to_ebitda, + financial_leverage = excluded.financial_leverage, + current_ratio = excluded.current_ratio, + quick_ratio = excluded.quick_ratio, + asset_turnover = excluded.asset_turnover, + inventory_turnover = excluded.inventory_turnover, + receivable_turnover = excluded.receivable_turnover, + revenue_growth = excluded.revenue_growth, + earnings_growth = excluded.earnings_growth, + eps = excluded.eps, + bvps = excluded.bvps, + revenue_per_share = excluded.revenue_per_share, + fcf_per_share = excluded.fcf_per_share, + dividend_per_share = excluded.dividend_per_share + `, stock.ID, today, metric.Year, metric.FiscalPeriodType, + metric.PriceToEarningsRatio, metric.PriceToBookRatio, metric.PriceToSalesRatio, metric.PriceToCashFlowRatio, metric.EVToEBITDA, + dividendYield, metric.PayoutRatio, metric.ROE, metric.ROA, metric.ROIC, + metric.GrossMargin, metric.OperatingMargin, metric.NetMargin, + metric.DebtToEquityRatio, metric.DebtToEBITDA, metric.FinancialLeverage, + metric.CurrentRatio, metric.QuickRatio, metric.AssetTurnover, metric.InventoryTurnover, metric.ReceivableTurnover, + metric.RevenueGrowthRate, metric.EarningsGrowthRate, metric.EarningsPerShare, metric.BookValuePerShare, + metric.RevenuePerShare, metric.FreeCashFlowPerShare, metric.DividendPerShare) + + if err != nil { + return err + } + } + return nil +} + +func (s *StockDB) saveFinancials(tx *sql.Tx, stock *StockData, today string) error { + for _, stmt := range stock.Financials.Statements { + // Save balance sheet + if stmt.BalanceSheets != nil { + currentAssetsJSON, _ := json.Marshal(stmt.BalanceSheets.CurrentAssets) + longTermAssetsJSON, _ := json.Marshal(stmt.BalanceSheets.LongTermAssets) + currentLiabilitiesJSON, _ := json.Marshal(stmt.BalanceSheets.CurrentLiabilities) + equityJSON, _ := json.Marshal(stmt.BalanceSheets.Equity) + + _, err := tx.Exec(` + INSERT INTO balance_sheets (stock_id, report_date, end_date, source, source_date, + current_assets_json, long_term_assets_json, current_liabilities_json, equity_json, currency, scrape_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, end_date) DO UPDATE SET + report_date = excluded.report_date, + source = excluded.source, + source_date = excluded.source_date, + current_assets_json = excluded.current_assets_json, + long_term_assets_json = excluded.long_term_assets_json, + current_liabilities_json = excluded.current_liabilities_json, + equity_json = excluded.equity_json, + currency = excluded.currency, + scrape_date = excluded.scrape_date + `, stock.ID, stmt.BalanceSheets.ReportDate, stmt.BalanceSheets.EndDate, stmt.BalanceSheets.Source, stmt.BalanceSheets.SourceDate, + string(currentAssetsJSON), string(longTermAssetsJSON), string(currentLiabilitiesJSON), string(equityJSON), + stmt.BalanceSheets.Currency, today) + + if err != nil { + return err + } + } + + // Save cash flow + if stmt.CashFlow != nil { + operatingJSON, _ := json.Marshal(stmt.CashFlow.Operating) + investingJSON, _ := json.Marshal(stmt.CashFlow.Investing) + financingJSON, _ := json.Marshal(stmt.CashFlow.Financing) + + _, err := tx.Exec(` + INSERT INTO cash_flows (stock_id, end_date, source, operating_json, investing_json, financing_json, currency, scrape_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, end_date) DO UPDATE SET + source = excluded.source, + operating_json = excluded.operating_json, + investing_json = excluded.investing_json, + financing_json = excluded.financing_json, + currency = excluded.currency, + scrape_date = excluded.scrape_date + `, stock.ID, stmt.CashFlow.EndDate, stmt.CashFlow.Source, + string(operatingJSON), string(investingJSON), string(financingJSON), + stmt.CashFlow.Currency, today) + + if err != nil { + return err + } + } + + // Save income statement + if stmt.IncomeStatements != nil { + revenueJSON, _ := json.Marshal(stmt.IncomeStatements.Revenue) + expensesJSON, _ := json.Marshal(stmt.IncomeStatements.Expenses) + + _, err := tx.Exec(` + INSERT INTO income_statements (stock_id, end_date, source, revenue_json, expenses_json, currency, scrape_date) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, end_date) DO UPDATE SET + source = excluded.source, + revenue_json = excluded.revenue_json, + expenses_json = excluded.expenses_json, + currency = excluded.currency, + scrape_date = excluded.scrape_date + `, stock.ID, stmt.IncomeStatements.EndDate, stmt.IncomeStatements.Source, + string(revenueJSON), string(expensesJSON), stmt.IncomeStatements.Currency, today) + + if err != nil { + return err + } + } + } + return nil +} + +func (s *StockDB) saveEarnings(tx *sql.Tx, stock *StockData, today string) error { + for _, e := range stock.Earnings { + _, err := tx.Exec(` + INSERT INTO earnings (stock_id, event_date, fiscal_year, fiscal_quarter, + eps_estimate, eps_actual, eps_surprise, eps_surprise_pct, + revenue_estimate, revenue_actual, revenue_surprise, scrape_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, event_date, fiscal_year, fiscal_quarter) DO UPDATE SET + eps_estimate = excluded.eps_estimate, + eps_actual = excluded.eps_actual, + eps_surprise = excluded.eps_surprise, + eps_surprise_pct = excluded.eps_surprise_pct, + revenue_estimate = excluded.revenue_estimate, + revenue_actual = excluded.revenue_actual, + revenue_surprise = excluded.revenue_surprise, + scrape_date = excluded.scrape_date + `, stock.ID, e.EventDate, e.FiscalYear, e.FiscalQuarter, + e.EPSEstimate, e.EPSActual, e.EPSSurprise, e.EPSSurprisePct, + e.RevenueEstimate, e.RevenueActual, e.RevenueSurprise, today) + + if err != nil { + return err + } + } + return nil +} + +func (s *StockDB) saveCharts(tx *sql.Tx, stock *StockData, today string) error { + for timeframe, points := range stock.Charts { + for _, p := range points { + _, err := tx.Exec(` + INSERT INTO charts (stock_id, timeframe, timestamp, open, high, low, close, volume, scrape_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, timeframe, timestamp) DO UPDATE SET + open = excluded.open, + high = excluded.high, + low = excluded.low, + close = excluded.close, + volume = excluded.volume, + scrape_date = excluded.scrape_date + `, stock.ID, timeframe, p.Time, p.Open, p.High, p.Low, p.Close, p.Volume, today) + + if err != nil { + return err + } + } + } + return nil +} + +func (s *StockDB) saveNews(tx *sql.Tx, stock *StockData, today string) error { + for _, n := range stock.News { + var sourceID, sourceName, imageURL string + var imageWidth, imageHeight int + + if n.Provider != nil { + sourceID = n.Provider.ID + sourceName = n.Provider.Name + } + if len(n.Images) > 0 { + imageURL = n.Images[0].URL + imageWidth = n.Images[0].Width + imageHeight = n.Images[0].Height + } + + // Categorize and score sentiment + category := categorizeNews(n.Title, n.Description) + sentiment, sentimentScore := scoreNewsSentiment(n.Title, n.Description) + isCritical := isNewsCritical(n.Title, n.Description) + + _, err := tx.Exec(` + INSERT INTO news (id, stock_id, title, url, abstract, source_id, source_name, + published_at, read_time_min, image_url, image_width, image_height, news_type, + category, sentiment, sentiment_score, is_critical, first_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + abstract = excluded.abstract, + category = excluded.category, + sentiment = excluded.sentiment, + sentiment_score = excluded.sentiment_score, + is_critical = excluded.is_critical + `, n.ID, stock.ID, n.Title, n.URL, n.Description, sourceID, sourceName, + n.PublishTime, n.ReadTimeMin, imageURL, imageWidth, imageHeight, n.Type, + category, sentiment, sentimentScore, isCritical, today) + + if err != nil { + return err + } + } + return nil +} + +func (s *StockDB) saveSentiment(tx *sql.Tx, stock *StockData, today string) error { + for _, stat := range stock.Sentiment.SentimentStatistics { + _, err := tx.Exec(` + INSERT INTO sentiment_history (stock_id, scrape_date, time_range, time_range_enum, + start_time, end_time, bullish, bearish, neutral, bullish_pct, bearish_pct, neutral_pct, scenario) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, scrape_date, time_range_enum) DO UPDATE SET + time_range = excluded.time_range, + start_time = excluded.start_time, + end_time = excluded.end_time, + bullish = excluded.bullish, + bearish = excluded.bearish, + neutral = excluded.neutral, + bullish_pct = excluded.bullish_pct, + bearish_pct = excluded.bearish_pct, + neutral_pct = excluded.neutral_pct, + scenario = excluded.scenario + `, stock.ID, today, stat.TimeRangeName, stat.TimeRangeEnum, + stat.StartTime, stat.EndTime, stat.Bullish, stat.Bearish, stat.Neutral, + stat.BullishPercent, stat.BearishPercent, stat.NeutralPercent, stat.Scenario) + + if err != nil { + return err + } + } + return nil +} + +func (s *StockDB) saveInsights(tx *sql.Tx, stock *StockData, today string) error { + highlightsJSON, _ := json.Marshal(stock.Insights.Highlights) + risksJSON, _ := json.Marshal(stock.Insights.Risks) + + _, err := tx.Exec(` + INSERT INTO insights (stock_id, scrape_date, summary, highlights_json, risks_json, last_updated) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(stock_id, scrape_date) DO UPDATE SET + summary = excluded.summary, + highlights_json = excluded.highlights_json, + risks_json = excluded.risks_json, + last_updated = excluded.last_updated + `, stock.ID, today, stock.Insights.Summary, string(highlightsJSON), string(risksJSON), stock.Insights.LastUpdated) + + return err +} + +func (s *StockDB) saveOwnership(tx *sql.Tx, stock *StockData, today string) error { + saveHolders := func(holders []Holder, holderType string) error { + for _, h := range holders { + _, err := tx.Exec(` + INSERT INTO ownership (stock_id, scrape_date, holder_type, investor_name, investor_type, + shares_held, shares_change, shares_pct, value, report_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, stock.ID, today, holderType, h.Name, h.Type, + h.SharesHeld, h.SharesChange, h.SharesPct, h.Value, h.ReportDate) + + if err != nil { + return err + } + } + return nil + } + + if stock.Ownership.TopHolders != nil { + if err := saveHolders(stock.Ownership.TopHolders, "top_holders"); err != nil { + return err + } + } + if stock.Ownership.TopBuyers != nil { + if err := saveHolders(stock.Ownership.TopBuyers, "top_buyers"); err != nil { + return err + } + } + if stock.Ownership.TopSellers != nil { + if err := saveHolders(stock.Ownership.TopSellers, "top_sellers"); err != nil { + return err + } + } + if stock.Ownership.NewHolders != nil { + if err := saveHolders(stock.Ownership.NewHolders, "new_holders"); err != nil { + return err + } + } + if stock.Ownership.ExitedHolders != nil { + if err := saveHolders(stock.Ownership.ExitedHolders, "exited_holders"); err != nil { + return err + } + } + + return nil +} + +// GetStaleStocks returns stocks not seen in the last N days +func (s *StockDB) GetStaleStocks(days int) ([]string, error) { + cutoff := time.Now().AddDate(0, 0, -days).Format("2006-01-02") + + rows, err := s.db.Query(` + SELECT ticker FROM stocks + WHERE last_seen < ? OR last_seen IS NULL + ORDER BY ticker + `, cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + + var tickers []string + for rows.Next() { + var ticker string + if err := rows.Scan(&ticker); err != nil { + return nil, err + } + tickers = append(tickers, ticker) + } + return tickers, nil +} + +// GetRunStats returns statistics for a run +func (s *StockDB) GetRunStats(runID int64) (pending, processing, success, failed int, err error) { + err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'pending'`, runID).Scan(&pending) + if err != nil { + return + } + err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'processing'`, runID).Scan(&processing) + if err != nil { + return + } + err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'success'`, runID).Scan(&success) + if err != nil { + return + } + err = s.db.QueryRow(`SELECT COUNT(*) FROM scrape_progress WHERE run_id = ? AND status = 'failed'`, runID).Scan(&failed) + return +} diff --git a/msn/idx_stocks.go b/msn/idx_stocks.go new file mode 100644 index 0000000..65c55f7 --- /dev/null +++ b/msn/idx_stocks.go @@ -0,0 +1,1844 @@ +// Generated from MSN Finance API - 898 Indonesian stock IDs (896 unique tickers) +// Last updated: 2026-02-16 +// Note: Some tickers map to multiple IDs (e.g., SOUL has 3 IDs) + +package msn + +// IDXStock represents an Indonesian stock listing +type IDXStock struct { + ID string // MSN internal ID + Name string // Short company name +} + +// IDXStocks maps ticker symbols to MSN stock info +// Use GetIDXStock(ticker) to look up a stock +var IDXStocks = map[string]IDXStock{ + "AADI": {ID: "cfatz2", Name: "Adaro Andalan"}, + "AALI": {ID: "bn8zk2", Name: "Astra Agro"}, + "ABBA": {ID: "bkf9ec", Name: "Mahaka Media"}, + "ABDA": {ID: "bkf9h7", Name: "Asuransi ABDA"}, + "ABMM": {ID: "bkf9k2", Name: "ABM Investama"}, + "ACES": {ID: "bn8zyc", Name: "Aspiration Life"}, + "ACRO": {ID: "cc48gh", Name: "Samcro Hyosung"}, + "ACST": {ID: "bkf9pr", Name: "Acset Indonusa"}, + "ADCP": {ID: "c59mf2", Name: "Adhi Commuter"}, + "ADES": {ID: "bkf9sm", Name: "Akasha Wira Intl"}, + "ADHI": {ID: "bn9127", Name: "Adhi Karya"}, + "ADMF": {ID: "bkf9yc", Name: "Adira Finance"}, + "ADMG": {ID: "bn9152", Name: "Polychem IDN"}, + "ADMR": {ID: "c4pl52", Name: "Alamtri Miner"}, + "ADRO": {ID: "bn917w", Name: "Alamtri Rsrcs"}, + "AEGS": {ID: "carobh", Name: "Anugerah Sparepa"}, + "AGAR": {ID: "bsu9xm", Name: "Asia Sejahtera"}, + "AGII": {ID: "bkfa7w", Name: "Samator Indo Gas"}, + "AGRO": {ID: "bkfaar", Name: "Bank Raya"}, + "AGRS": {ID: "bkfadm", Name: "Bank IBK"}, + "AHAP": {ID: "bkfagh", Name: "Asuransi Harta"}, + "AIMS": {ID: "bkfajc", Name: "Artha Mahiya Inv"}, + "AISA": {ID: "bkfam7", Name: "FKS Food Sejahte"}, + "AKKU": {ID: "bkfctc", Name: "Anugerah Kagum"}, + "AKPI": {ID: "bkfcw7", Name: "Argha Karya"}, + "AKRA": {ID: "bkfcz2", Name: "AKR Corporindo"}, + "AKSI": {ID: "bkfd2w", Name: "Mineral Sumberda"}, + "ALDO": {ID: "bkfd5r", Name: "Alkindo Naratama"}, + "ALII": {ID: "ccanzr", Name: "Ancara Logistics"}, + "ALKA": {ID: "bkfd8m", Name: "Alakasa Industri"}, + "ALMI": {ID: "bkfdbh", Name: "Alumindo Light"}, + "ALTO": {ID: "bkfdec", Name: "Tri Banyan Tirta"}, + "AMAG": {ID: "bkfchw", Name: "Asuransi MAG"}, + "AMAN": {ID: "btysar", Name: "Makmur Berkah"}, + "AMAR": {ID: "btf6a2", Name: "Bk Amar"}, + "AMFG": {ID: "bkfckr", Name: "Asahimas Flat"}, + "AMIN": {ID: "bkfcnm", Name: "Atmindo"}, + "AMMN": {ID: "ca993m", Name: "Amman Mineral In"}, + "AMMS": {ID: "c6yaw7", Name: "Agung Menjangan"}, + "AMOR": {ID: "btfbw7", Name: "Ashmore Asset"}, + "AMRT": {ID: "bkfcqh", Name: "Sumber Alfaria"}, + "ANDI": {ID: "bgrwf2", Name: "Andira Agro"}, + "ANJT": {ID: "bkfem7", Name: "Austindo"}, + "ANTM": {ID: "bn8zmw", Name: "ANTM"}, + "APEX": {ID: "bkferw", Name: "Apexindo Pratama"}, + "APIC": {ID: "bkfeur", Name: "Pac Strategic"}, + "APII": {ID: "bkfexm", Name: "Arita Prima ID"}, + "APLI": {ID: "bkff1h", Name: "Asiaplast Inds"}, + "APLN": {ID: "bkff4c", Name: "Agung Podomoro"}, + "ARCI": {ID: "c2il1h", Name: "Archi Indonesia"}, + "AREA": {ID: "ccsom7", Name: "Dunia Virtual"}, + "ARGO": {ID: "bkff77", Name: "Argo Pantes"}, + "ARII": {ID: "bkffa2", Name: "Atlas Resources"}, + "ARKA": {ID: "bqtwtc", Name: "Arkha Jayanti"}, + "ARKO": {ID: "c6qaqh", Name: "Arkora Hydro"}, + "ARNA": {ID: "bkfffr", Name: "Arwana"}, + "ARTA": {ID: "bkffim", Name: "Arthavest"}, + "ARTO": {ID: "bkffoc", Name: "Bank Jago"}, + "ASBI": {ID: "bkffzr", Name: "Asuransi Bintang"}, + "ASDM": {ID: "bkfg3m", Name: "Asuransi Dayin"}, + "ASGR": {ID: "bkfg6h", Name: "Astra Graphia"}, + "ASHA": {ID: "c6a3yc", Name: "Cilacap Samudera"}, + "ASII": {ID: "bn91gh", Name: "Astra Intl"}, + "ASJT": {ID: "bkfgc7", Name: "Asuransi Jastan"}, + "ASLC": {ID: "c4xl52", Name: "Autopedia Sukses"}, + "ASLI": {ID: "cc41ww", Name: "Asri Karya"}, + "ASMI": {ID: "bkfgf2", Name: "Asuransi Maximus"}, + "ASPI": {ID: "btr8pr", Name: "Andalan Sakti"}, + "ASPR": {ID: "chbyjc", Name: "Asia Pramulia"}, + "ASRI": {ID: "bkfghw", Name: "Alam Sutera Rlty"}, + "ASRM": {ID: "bkfgkr", Name: "Asuransi Rmyn"}, + "ASSA": {ID: "bkfgnm", Name: "Adi Sarana"}, + "ATAP": {ID: "bwzup2", Name: "Trimitra Prawara"}, + "ATIC": {ID: "bkffr7", Name: "Anabatic Tech"}, + "ATLA": {ID: "ccwjvh", Name: "Atlantis Subsea"}, + "AUTO": {ID: "bkffu2", Name: "Astra Otoparts"}, + "AVIA": {ID: "c4bepr", Name: "Avia Avian Pt"}, + "AWAN": {ID: "c9f9yc", Name: "PT Era Digital"}, + "AXIO": {ID: "c6v4pr", Name: "Tera Data"}, + "AYAM": {ID: "cbkfkr", Name: "JANU PUTRA"}, + "AYLS": {ID: "btox8m", Name: "Agro Yasa"}, + "BABP": {ID: "bkfgqh", Name: "Bank MNC"}, + "BABY": {ID: "carnqh", Name: "Multitrend Indo"}, + "BACA": {ID: "bkfgtc", Name: "Bank Capital ID"}, + "BAIK": {ID: "ccbzar", Name: "Bersama Menca"}, + "BAJA": {ID: "bkfgw7", Name: "Saranacentral"}, + "BALI": {ID: "bkfgz2", Name: "Bali Towerindo"}, + "BANK": {ID: "bxjrc7", Name: "PT Bank Aladin"}, + "BAPA": {ID: "bkfh2w", Name: "Bekasi Asri"}, + "BAPI": {ID: "breeur", Name: "Bhakti Agung"}, + "BATA": {ID: "bkflyc", Name: "Sepatu Bata"}, + "BATR": {ID: "cddzvh", Name: "Benteng Api Tech"}, + "BAUT": {ID: "c4xpm7", Name: "Mitra Angkasa"}, + "BAYU": {ID: "bkfm27", Name: "Bayu Buana"}, + "BBCA": {ID: "bn91jc", Name: "Bank Cntrl Asia"}, + "BBHI": {ID: "bkfm7w", Name: "Allo Bank"}, + "BBKP": {ID: "bkfmar", Name: "Bank KB"}, + "BBLD": {ID: "bkfmdm", Name: "Buana Finance"}, + "BBMD": {ID: "bkfmgh", Name: "Bank Mestika"}, + "BBNI": {ID: "bn91m7", Name: "Bank BNI"}, + "BBRI": {ID: "bn91p2", Name: "Bank BRI"}, + "BBRM": {ID: "bkfmrw", Name: "Bina Buana Raya"}, + "BBSI": {ID: "bvpgvh", Name: "Krom Bank"}, + "BBSS": {ID: "bu9cqh", Name: "Bumi Benowo"}, + "BBTN": {ID: "bkfmur", Name: "Bank BTN"}, + "BBYB": {ID: "bkfmxm", Name: "PT Bank Neo"}, + "BCAP": {ID: "bkfn1h", Name: "MNC Kapital ID"}, + "BCIC": {ID: "bgt4rw", Name: "Bank JTrust ID"}, + "BCIP": {ID: "bkfn4c", Name: "Bumi Citra"}, + "BDKR": {ID: "c8zmyc", Name: "Berdikari Pondas"}, + "BDMN": {ID: "bn91rw", Name: "Bank Danamon"}, + "BEBS": {ID: "bzv7ar", Name: "Berkah Beton"}, + "BEEF": {ID: "bokavh", Name: "Estika Tata"}, + "BEER": {ID: "c8if77", Name: "Jobubu Jarum"}, + "BEKS": {ID: "bkfna2", Name: "Bank Banten"}, + "BELI": {ID: "c7svnm", Name: "Global Digital"}, + "BELL": {ID: "bkfncw", Name: "Trisula Textile"}, + "BESS": {ID: "btyakr", Name: "Batulicin"}, + "BEST": {ID: "bkfh5r", Name: "Bekasi Fajar"}, + "BFIN": {ID: "bkfh8m", Name: "BFI Finance ID"}, + "BGTG": {ID: "bkfhbh", Name: "Bank Ganesha"}, + "BHAT": {ID: "bua6k2", Name: "Bhakti Multi"}, + "BHIT": {ID: "bkfhec", Name: "MNC Asia Hldg"}, + "BIKE": {ID: "c5iucw", Name: "Sepeda Bersama"}, + "BIMA": {ID: "bkfkz2", Name: "Primarindo Asia"}, + "BINA": {ID: "bn92u2", Name: "Bank Ina"}, + "BINO": {ID: "c45tp2", Name: "Perma Plasindo"}, + "BIPI": {ID: "bn92ww", Name: "Astrindo Nusan"}, + "BIPP": {ID: "bkfl8m", Name: "Bhuwanatala Inda"}, + "BIRD": {ID: "bkflbh", Name: "Blue Brd"}, + "BISI": {ID: "bn92zr", Name: "BISI Intl"}, + "BJBR": {ID: "bkflh7", Name: "Bank BJB"}, + "BJTM": {ID: "bn936h", Name: "Bank Jatim"}, + "BKDP": {ID: "bkflmw", Name: "Bukit Darmo"}, + "BKSL": {ID: "bkflpr", Name: "Sentul City"}, + "BKSW": {ID: "bkflsm", Name: "Bank QNB ID"}, + "BLES": {ID: "cdlvh7", Name: "Superior Prima"}, + "BLOG": {ID: "chc7m7", Name: "Trimitra Trans"}, + "BLTA": {ID: "bgt5u2", Name: "Berlian Laju"}, + "BLTZ": {ID: "bkflvh", Name: "Graha Layar"}, + "BLUE": {ID: "bqrgar", Name: "Berkah Prima"}, + "BMAS": {ID: "bkfoqh", Name: "Bank Maspion ID"}, + "BMBL": {ID: "c8isqh", Name: "Lavender Bina"}, + "BMHS": {ID: "c2md4c", Name: "Bundamedik"}, + "BMRI": {ID: "bn939c", Name: "Bank Mandiri"}, + "BMSR": {ID: "bkfow7", Name: "Bintang Mitra"}, + "BMTR": {ID: "bkfoz2", Name: "Global Mediacom"}, + "BNBA": {ID: "bkfp2w", Name: "Bank Bumi Arta"}, + "BNBR": {ID: "bn93c7", Name: "Bakrie& Brothers"}, + "BNGA": {ID: "bkfp8m", Name: "Bank CIMB Niaga"}, + "BNII": {ID: "bn93f2", Name: "Bank Maybank ID"}, + "BNLI": {ID: "bn93hw", Name: "Bank Permata"}, + "BOAT": {ID: "cf4yu2", Name: "Newport Marine"}, + "BOBA": {ID: "c3wioc", Name: "Formosa"}, + "BOGA": {ID: "bkfph7", Name: "Apollo Glo"}, + "BOLA": {ID: "bqe3p2", Name: "Bali Bintang"}, + "BOLT": {ID: "bkfpk2", Name: "Garuda Metalindo"}, + "BPFI": {ID: "bkfppr", Name: "Woori Finance"}, + "BPII": {ID: "bkfpsm", Name: "Batavia Prosperi"}, + "BPTR": {ID: "bgth1h", Name: "Batavia"}, + "BRAM": {ID: "bkfpvh", Name: "Indo Kordsa"}, + "BREN": {ID: "cb1ra2", Name: "Barito Renewable"}, + "BRIS": {ID: "bgtha2", Name: "Bank BRISyariah"}, + "BRMS": {ID: "bn924c", Name: "Bumi Resource Mn"}, + "BRNA": {ID: "bkfq27", Name: "Berlina"}, + "BRPT": {ID: "bn9277", Name: "Barito Pacific"}, + "BRRC": {ID: "cflfa2", Name: "Raja Roti"}, + "BSBK": {ID: "c7svhw", Name: "Wulandari Bangun"}, + "BSDE": {ID: "bkfq7w", Name: "Bumi Serpong"}, + "BSIM": {ID: "bn92a2", Name: "Bank Sinarmas"}, + "BSML": {ID: "c4is3m", Name: "Bintang Samudera"}, + "BSSR": {ID: "bkfqdm", Name: "Baramulti Sukses"}, + "BSWD": {ID: "bkfqgh", Name: "Bank Of India ID"}, + "BTEK": {ID: "bkfqjc", Name: "Bumi Teknokultur"}, + "BTON": {ID: "bkfqp2", Name: "Betonjaya"}, + "BTPN": {ID: "bkfqrw", Name: "Bank SMBC"}, + "BTPS": {ID: "bgtwjc", Name: "Bank BTPN ID"}, + "BUAH": {ID: "c71dyc", Name: "Segar Kumala"}, + "BUDI": {ID: "bkfqur", Name: "Budi Starch"}, + "BUKA": {ID: "c2wgkr", Name: "Bukalapak.com"}, + "BUKK": {ID: "bkfqxm", Name: "Bukaka Teknik"}, + "BULL": {ID: "bkfnu2", Name: "BULL Indonesia"}, + "BUMI": {ID: "bn92fr", Name: "Bumi Resources"}, + "BUVA": {ID: "bkfnzr", Name: "Bukit Uluwatu"}, + "BVIC": {ID: "bkfo3m", Name: "Bank Victoria"}, + "BWPT": {ID: "bn92im", Name: "Eagle High Plnt"}, + "BYAN": {ID: "bn92lh", Name: "Bayan Resources"}, + "CAKK": {ID: "bne4c7", Name: "Cahayaputra Asa"}, + "CAMP": {ID: "bkfoc7", Name: "Campina Ice"}, + "CANI": {ID: "bkfof2", Name: "Capitol Nusan"}, + "CARE": {ID: "btysdm", Name: "Metro Healthcare"}, + "CARS": {ID: "bkfohw", Name: "Industri Dan"}, + "CASA": {ID: "bkfokr", Name: "Capital Fincl ID"}, + "CASH": {ID: "bufc7w", Name: "Cashlez"}, + "CASS": {ID: "bkfonm", Name: "Cahaya Aero"}, + "CBDK": {ID: "cflejc", Name: "Bangun Kosambi"}, + "CBPE": {ID: "c8ipmw", Name: "PT Citra Buana"}, + "CBRE": {ID: "c8ifww", Name: "Cakra Buana Reso"}, + "CBUT": {ID: "c7sxgh", Name: "CBUT"}, + "CCSI": {ID: "bqh6ar", Name: "Commn Cbl Sys"}, + "CDIA": {ID: "chdaw7", Name: "Chandra Daya In"}, + "CEKA": {ID: "bkfkqh", Name: "Wilmar Cahaya ID"}, + "CENT": {ID: "bkfktc", Name: "Centratama Tele"}, + "CFIN": {ID: "bkfkw7", Name: "Clipan Finance"}, + "CGAS": {ID: "cc45zr", Name: "Citra Nusantara"}, + "CHEK": {ID: "chddzr", Name: "Diastika Bioteki"}, + "CHEM": {ID: "c6qahw", Name: "Chemstar"}, + "CHIP": {ID: "c8qlmw", Name: "Pelita Teknologi"}, + "CINT": {ID: "bkfnfr", Name: "Chitose"}, + "CITA": {ID: "bkfnim", Name: "Cita Mineral"}, + "CITY": {ID: "bjs6w7", Name: "Natura City Devs"}, + "CLAY": {ID: "bona52", Name: "Citra Putra"}, + "CLEO": {ID: "bkfnoc", Name: "Sariguna"}, + "CLPI": {ID: "bkfnr7", Name: "Colorpak ID"}, + "CMNP": {ID: "bn93kr", Name: "Citra Marga Nusa"}, + "CMNT": {ID: "c3bxcw", Name: "Cemindo Gemilang"}, + "CMPP": {ID: "bkgku2", Name: "AirAsia Indo"}, + "CMRY": {ID: "c4b81h", Name: "Cisarua Mountain"}, + "CNKO": {ID: "bkgkzr", Name: "Exploitasi Energ"}, + "CNMA": {ID: "cagkkr", Name: "NSR"}, + "COAL": {ID: "c78lp2", Name: "Black Diamond"}, + "COCO": {ID: "bpfy1h", Name: "Wahana Interfood"}, + "COIN": {ID: "chc7p2", Name: "Indokripto Koin"}, + "CPIN": {ID: "bn93nm", Name: "Charoen Pokphand"}, + "CPRO": {ID: "bkgl9c", Name: "CP Prima"}, + "CRAB": {ID: "c71ep2", Name: "Toba Surimi Inds"}, + "CRSN": {ID: "ca9dnm", Name: "Carsurin"}, + "CSAP": {ID: "bn93z2", Name: "Catur Sentosa"}, + "CSIS": {ID: "bkglf2", Name: "CSIS"}, + "CSMI": {ID: "bua4a2", Name: "Cipta Selera"}, + "CSRA": {ID: "btf6cw", Name: "Cisadane Sawit"}, + "CTBN": {ID: "bkglhw", Name: "Citra Tubindo"}, + "CTRA": {ID: "bn942w", Name: "Ciputra Dvlpmt"}, + "CTTH": {ID: "bkglnm", Name: "Citatah"}, + "CUAN": {ID: "c8ztf2", Name: "Petrindo Jaya"}, + "CYBR": {ID: "caj9jc", Name: "Itsec Asia"}, + "DAAZ": {ID: "cf3dr7", Name: "Daaz Bara"}, + "DADA": {ID: "btov1h", Name: "Diamond Citra"}, + "DART": {ID: "bkglqh", Name: "Duta Anggada"}, + "DATA": {ID: "cd3e27", Name: "Remala Abadi"}, + "DAYA": {ID: "bkgltc", Name: "Duta Intidaya"}, + "DCII": {ID: "bxbr3m", Name: "DCI Indonesia"}, + "DEFI": {ID: "bkglw7", Name: "Danapac"}, + "DEPO": {ID: "c461k2", Name: "Caturkarda Depo"}, + "DEWA": {ID: "bn93qh", Name: "Darma Henwa"}, + "DEWI": {ID: "c6t4oc", Name: "Dewi Shri Farmin"}, + "DFAM": {ID: "bkgm2w", Name: "Dafam Property"}, + "DGIK": {ID: "bkgm5r", Name: "Nusa Konstruksi"}, + "DGNS": {ID: "bxbs2w", Name: "Diagnos"}, + "DGWG": {ID: "cfl46h", Name: "Delta Giri"}, + "DIGI": {ID: "bguqr7", Name: "Arkadia Dig"}, + "DILD": {ID: "bn93tc", Name: "Intiland Dvlpmt"}, + "DIVA": {ID: "bny8f2", Name: "Nusantara"}, + "DKFT": {ID: "bkgmbh", Name: "Central Omega"}, + "DKHH": {ID: "cgo8rw", Name: "Cipta Sarana Med"}, + "DLTA": {ID: "bkgn52", Name: "Delta Djakarta"}, + "DMAS": {ID: "bkgn7w", Name: "Puradelta Lestar"}, + "DMMX": {ID: "bsh6z2", Name: "Digital Mediatam"}, + "DMND": {ID: "bti2qh", Name: "Diamond Food"}, + "DNAR": {ID: "bkgnar", Name: "PT Bank Oke"}, + "DNET": {ID: "bkgndm", Name: "Indoritel Makmur"}, + "DOID": {ID: "bkgngh", Name: "BUMA Internasion"}, + "DOOH": {ID: "c9pg52", Name: "Era Media"}, + "DOSS": {ID: "cecd8m", Name: "Glo Sukses Dig"}, + "DPNS": {ID: "bkgnjc", Name: "Duta Pertiwi Nus"}, + "DPUM": {ID: "bkgnm7", Name: "Dua Putra Utama"}, + "DRMA": {ID: "c4is9c", Name: "Dharma Polimetal"}, + "DSFI": {ID: "bkgnp2", Name: "Dharma Samudera"}, + "DSNG": {ID: "bkgnrw", Name: "Dharma Satya"}, + "DSSA": {ID: "bn94ec", Name: "Dian Swastatika"}, + "DUTI": {ID: "bn94h7", Name: "Duta Pertiwi"}, + "DVLA": {ID: "bkgmsm", Name: "Darya-Varia"}, + "DWGL": {ID: "bkgmvh", Name: "Dwi Guna"}, + "DYAN": {ID: "bkgmyc", Name: "Dyandra Media"}, + "EAST": {ID: "bqrjmw", Name: "Eastparc Hotel"}, + "ECII": {ID: "bkgn27", Name: "Electronic City"}, + "EDGE": {ID: "bxmmkr", Name: "Indointernet"}, + "EKAD": {ID: "bkgmec", Name: "Ekadharma Intl"}, + "ELIT": {ID: "c8ifcw", Name: "Data Sinergitama"}, + "ELPI": {ID: "c71dh7", Name: "Pelayaran Nasion"}, + "ELSA": {ID: "bkgmh7", Name: "Elnusa"}, + "ELTY": {ID: "bn94k2", Name: "Bakrieland Dev"}, + "EMAS": {ID: "ci4x5r", Name: "Merdeka Gold Res"}, + "EMDE": {ID: "bkgo77", Name: "Megapolitan Dev"}, + "EMTK": {ID: "bkgocw", Name: "Emtek Indonesia"}, + "ENAK": {ID: "c53nww", Name: "Champ Resto"}, + "ENRG": {ID: "bkgofr", Name: "Energi Mega Pers"}, + "ENZO": {ID: "bvqna2", Name: "Morenzo Abadi"}, + "EPAC": {ID: "buzekr", Name: "Megalestari"}, + "EPMT": {ID: "bkgoim", Name: "Enseval Putera"}, + "ERAA": {ID: "bkgolh", Name: "Erajaya"}, + "ERAL": {ID: "cakb4c", Name: "Sinar Eka Selara"}, + "ERTX": {ID: "bkgooc", Name: "Eratex Djaja"}, + "ESIP": {ID: "bsq1dm", Name: "Sinergi Inti"}, + "ESSA": {ID: "bkgnxm", Name: "ESSA Industries"}, + "ESTA": {ID: "btxltc", Name: "Esta Multi Usaha"}, + "ESTI": {ID: "bkgo1h", Name: "Ever Shine Tex"}, + "EURO": {ID: "c6zylh", Name: "Estee Gold Feet"}, + "EXCL": {ID: "bn94pr", Name: "XLSMART Telecom"}, + "FAPA": {ID: "bx7mww", Name: "FAP Agri"}, + "FAST": {ID: "bkgor7", Name: "Fast Food ID"}, + "FASW": {ID: "bkgou2", Name: "Fajar Surya"}, + "FILM": {ID: "bgva7w", Name: "MD Ent"}, + "FIMP": {ID: "c1paqh", Name: "Fimperkasa Utama"}, + "FIRE": {ID: "bkgozr", Name: "AEI"}, + "FISH": {ID: "bkgp3m", Name: "FKS Multi Agro"}, + "FITT": {ID: "bqcw2w", Name: "Hotel Fitra Intl"}, + "FLMC": {ID: "c2p22w", Name: "Falmaco Nonwoven"}, + "FMII": {ID: "bkgp9c", Name: "Fortune Mate ID"}, + "FOLK": {ID: "caj9ar", Name: "Multi Garam Utam"}, + "FOOD": {ID: "bohgur", Name: "Sentra Food ID"}, + "FORE": {ID: "cghp7w", Name: "Fore Kopi"}, + "FORU": {ID: "bkgpc7", Name: "Fortune ID"}, + "FPNI": {ID: "bkgphw", Name: "Lotte Chem Titan"}, + "FUJI": {ID: "bqrhww", Name: "Fuji Finance ID"}, + "FUTR": {ID: "c8xc2w", Name: "PT Futura Energi"}, + "FWCT": {ID: "c8no4c", Name: "Wijaya Cahaya"}, + "GDST": {ID: "bkgpnm", Name: "Gunawan Dianjaya"}, + "GDYR": {ID: "bkgpqh", Name: "Goodyear"}, + "GEMA": {ID: "bkgptc", Name: "Gema Grahasarana"}, + "GEMS": {ID: "bkgpw7", Name: "Golden Energy"}, + "GGRM": {ID: "bn94vh", Name: "Gudang Garam"}, + "GGRP": {ID: "brivww", Name: "Gunung Raja"}, + "GHON": {ID: "bkgq2w", Name: "Gihon"}, + "GIAA": {ID: "bkgq5r", Name: "Garuda Indonesia"}, + "GJTL": {ID: "bn9527", Name: "Gajah Tunggal"}, + "GLOB": {ID: "bgveur", Name: "Globe Kita"}, + "GLVA": {ID: "bt94fr", Name: "Galva Tech"}, + "GMFI": {ID: "bkgqbh", Name: "GMF Aero Asia"}, + "GMTD": {ID: "bkgqk2", Name: "Gowa Makassar"}, + "GOLD": {ID: "bkgqmw", Name: "Visi"}, + "GOLF": {ID: "cdml52", Name: "Pt Intra"}, + "GOOD": {ID: "bkgqsm", Name: "Garudafood Putra"}, + "GOTO": {ID: "c5tv8m", Name: "Gojek"}, + "GPRA": {ID: "bkgqvh", Name: "Perdana Gapura"}, + "GPSO": {ID: "c39qp2", Name: "Geoprima Solusi"}, + "GRIA": {ID: "cakam7", Name: "Ingria Pratama"}, + "GRPH": {ID: "cc65vh", Name: "Griptha Putra"}, + "GRPM": {ID: "ca9bar", Name: "Graha Prima"}, + "GSMF": {ID: "bkgqec", Name: "Eqty Dvlpmt Inv"}, + "GTBO": {ID: "bkgqh7", Name: "Garda Tujuh"}, + "GTRA": {ID: "c9a7c7", Name: "Grahaprima"}, + "GTSI": {ID: "c3atkr", Name: "Gts Internasiona"}, + "GULA": {ID: "c6ybbh", Name: "Aman Agrindo"}, + "GUNA": {ID: "cdm6oc", Name: "PT Gunanusa"}, + "GWSA": {ID: "bkgqyc", Name: "Greenwood Sejaht"}, + "GZCO": {ID: "bkgr27", Name: "Gozco Plantation"}, + "HADE": {ID: "bkgr52", Name: "Himalaya EP"}, + "HAIS": {ID: "c3839c", Name: "Hasnur"}, + "HAJJ": {ID: "c9c9z2", Name: "Arsy Buana"}, + "HALO": {ID: "c8snbh", Name: "Haloni Jane"}, + "HATM": {ID: "c6vxxm", Name: "Habco Trans"}, + "HBAT": {ID: "caj5ur", Name: "Minahasa"}, + "HDFA": {ID: "bkgr7w", Name: "Radana Bhaskara"}, + "HDIT": {ID: "bqsr9c", Name: "Hensel Davest"}, + "HEAL": {ID: "bgvigh", Name: "Mediakaloka"}, + "HELI": {ID: "bkgrdm", Name: "Jaya Trishindo"}, + "HERO": {ID: "bkgrgh", Name: "DFI Retail"}, + "HEXA": {ID: "bn95ar", Name: "Hexindo Adiperka"}, + "HGII": {ID: "cfl3r7", Name: "Hero Global Inv"}, + "HILL": {ID: "c8xfhw", Name: "Hillcon"}, + "HITS": {ID: "bkgrm7", Name: "Humpuss Intermod"}, + "HMSP": {ID: "bn95jc", Name: "HM Sampoerna"}, + "HOKI": {ID: "bkgrur", Name: "Buyung Poetra"}, + "HOMI": {ID: "bvpl7w", Name: "Grand House"}, + "HOPE": {ID: "c24y52", Name: "Harapan Duta"}, + "HRME": {ID: "bpplhw", Name: "Menteng Heritage"}, + "HRTA": {ID: "bkgs4c", Name: "Hartadinata"}, + "HRUM": {ID: "bn957w", Name: "Harum Energy"}, + "HUMI": {ID: "caj9p2", Name: "HUMI"}, + "HYGN": {ID: "ccbzjc", Name: "Ecocare Indo"}, + "IATA": {ID: "bkgsa2", Name: "PT MNC Energy"}, + "IBFN": {ID: "bkgscw", Name: "Intan Baru Prana"}, + "IBOS": {ID: "c5z1vh", Name: "Indo Boga Sukses"}, + "IBST": {ID: "bkgsfr", Name: "Inti Bangun"}, + "ICBP": {ID: "bkgsim", Name: "Indofood CBP"}, + "ICON": {ID: "bkgslh", Name: "Island Concepts"}, + "IDEA": {ID: "c3at9c", Name: "IDeA Indonesia"}, + "IDPR": {ID: "bkgsoc", Name: "Indopora"}, + "IFII": {ID: "bt26w7", Name: "Indonesia Fibre"}, + "IFSH": {ID: "bsua4c", Name: "Ifishdeco"}, + "IGAR": {ID: "bkgsr7", Name: "Champion Pacific"}, + "IKAI": {ID: "bkgsww", Name: "Intikeramik Alam"}, + "IKAN": {ID: "btov4c", Name: "EMC"}, + "IKBI": {ID: "bkgszr", Name: "Sumi Indo Kabel"}, + "IKPM": {ID: "cbetnm", Name: "Ikapharmindo"}, + "IMAS": {ID: "bkguk2", Name: "Indomobil Sukses"}, + "IMJS": {ID: "bkgumw", Name: "Indomobil Multi"}, + "IMPC": {ID: "bkgupr", Name: "Impack Pratama"}, + "INAI": {ID: "bkguvh", Name: "Indal Aluminium"}, + "INCF": {ID: "bkguyc", Name: "Indo Komoditi"}, + "INCI": {ID: "bkgv27", Name: "Intanwijaya Intl"}, + "INCO": {ID: "bn96cw", Name: "Vale Indonesia"}, + "INDF": {ID: "bn96fr", Name: "Indofood Sukses"}, + "INDO": {ID: "btf6ww", Name: "Royalindo Inv"}, + "INDR": {ID: "bkgvar", Name: "Indo-Rama"}, + "INDS": {ID: "bkgvdm", Name: "Indospring"}, + "INDX": {ID: "bkgvgh", Name: "Tanah Laut"}, + "INDY": {ID: "bkgvjc", Name: "Indika Energy"}, + "INET": {ID: "caehu2", Name: "Sinergi Inti"}, + "INKP": {ID: "bn96im", Name: "Indah Kiat"}, + "INOV": {ID: "bqtw6h", Name: "Inocycle"}, + "INPC": {ID: "bkgttc", Name: "Bank Artha Graha"}, + "INPP": {ID: "bkgtw7", Name: "ID Paradise"}, + "INPS": {ID: "bkgtz2", Name: "Indah Prakasa"}, + "INRU": {ID: "bkgu2w", Name: "Toba Pulp ID"}, + "INTA": {ID: "bkgu5r", Name: "Intraco Penta"}, + "INTD": {ID: "bkgu8m", Name: "Inter Delta"}, + "INTP": {ID: "bn95m7", Name: "Indocement"}, + "IOTF": {ID: "cb1mar", Name: "Sumber Sinergi"}, + "IPAC": {ID: "c2ip4c", Name: "ERA Graharealty"}, + "IPCC": {ID: "bgvlmw", Name: "IPCC"}, + "IPCM": {ID: "bkguec", Name: "Jasa Armada"}, + "IPOL": {ID: "bkguh7", Name: "Indopoly Swakars"}, + "IPTV": {ID: "bqrdim", Name: "MNC Vision"}, + "IRRA": {ID: "bs6n9c", Name: "Itama Ranoraya"}, + "IRSX": {ID: "c8ptdm", Name: "Aviana Sinar Aba"}, + "ISAP": {ID: "c87m5r", Name: "Isra Presisi"}, + "ISAT": {ID: "bn95p2", Name: "Indosat"}, + "ISEA": {ID: "cdmkyc", Name: "Indo American"}, + "ISSP": {ID: "bn95rw", Name: "Steel Pipe"}, + "ITIC": {ID: "bqptyc", Name: "Indonesian Tobac"}, + "ITMA": {ID: "bkgt9c", Name: "Sumber Energi"}, + "ITMG": {ID: "bn95ur", Name: "ITM"}, + "JARR": {ID: "c6yb2w", Name: "Jhonlin Agro Ray"}, + "JAST": {ID: "bpztur", Name: "Jasnita"}, + "JATI": {ID: "c9pikr", Name: "Informasi"}, + "JAWA": {ID: "bkgtf2", Name: "Jaya Agra Wattie"}, + "JAYA": {ID: "bp3fkr", Name: "Armada Berjaya"}, + "JECC": {ID: "bkgthw", Name: "Jembo Cable Co"}, + "JGLE": {ID: "bkgtkr", Name: "Graha"}, + "JIHD": {ID: "bkgtnm", Name: "Jakarta Intl"}, + "JKON": {ID: "bn95xm", Name: "Jaya Konstruksi"}, + "JMAS": {ID: "bkgwcw", Name: "Asuransi Jiwa"}, + "JPFA": {ID: "bkgwfr", Name: "Japfa Comfeed ID"}, + "JRPT": {ID: "bkgwim", Name: "Jaya Real Prpty"}, + "JSMR": {ID: "bn964c", Name: "Jasa Marga"}, + "JSPT": {ID: "bkgwr7", Name: "JKT Setiabudi"}, + "JTPE": {ID: "bkgwu2", Name: "Jasuindo Tiga"}, + "KAEF": {ID: "bkgvp2", Name: "Kimia Farma"}, + "KAQI": {ID: "cg3ah7", Name: "Jantra Grupo"}, + "KARW": {ID: "bkgvrw", Name: "Meratus Jasa"}, + "KBAG": {ID: "bu8obh", Name: "Karya Bersama"}, + "KBLI": {ID: "bkgvur", Name: "KMI Wire & Cable"}, + "KBLM": {ID: "bkgvxm", Name: "Kabelindo Murni"}, + "KBLV": {ID: "bkgw1h", Name: "First Media ID"}, + "KDSI": {ID: "bkgwww", Name: "Kedawung Indl"}, + "KDTN": {ID: "c7sxar", Name: "Puri Sentul"}, + "KEEN": {ID: "brdma2", Name: "Kencana Energi"}, + "KEJU": {ID: "bss8im", Name: "Mulia Boga Raya"}, + "KETR": {ID: "c7x3cw", Name: "Ketrosden"}, + "KIAS": {ID: "bgw8lh", Name: "Keramika ID"}, + "KICI": {ID: "bkgwzr", Name: "Kedaung Indah"}, + "KIJA": {ID: "bkgx3m", Name: "Jababeka"}, + "KING": {ID: "c8v3jc", Name: "Hoffmen Cleanind"}, + "KINO": {ID: "bkgx6h", Name: "Kino Indonesia"}, + "KIOS": {ID: "bkgx9c", Name: "Kioson Komersial"}, + "KJEN": {ID: "bqoa1h", Name: "Krida Jaringan"}, + "KKES": {ID: "c71dsm", Name: "Kusuma Kemindo"}, + "KKGI": {ID: "bkgxqh", Name: "Resource Alam ID"}, + "KLAS": {ID: "c9zg9c", Name: "Pelayaran Kurnia"}, + "KLBF": {ID: "bn96u2", Name: "Kalbe Farma"}, + "KLIN": {ID: "c71ear", Name: "Klinko Karya Ima"}, + "KMDS": {ID: "bvpjz2", Name: "KMDS"}, + "KMTR": {ID: "bkgxw7", Name: "Kirana Megatara"}, + "KOBX": {ID: "bkgxz2", Name: "Kobexindo"}, + "KOCI": {ID: "cazma2", Name: "Kokoh Exa"}, + "KOIN": {ID: "bkgy2w", Name: "Kokoh Inti"}, + "KOKA": {ID: "cb2m5r", Name: "Koka Indonesia"}, + "KONI": {ID: "bkgy5r", Name: "Perdana Bangun"}, + "KOPI": {ID: "bkgy8m", Name: "Mitra Energi"}, + "KOTA": {ID: "bqspm7", Name: "DMS Propertindo"}, + "KPIG": {ID: "bkgxf2", Name: "MNC Tourism Indo"}, + "KRAS": {ID: "bn96zr", Name: "Krakatau Steel"}, + "KREN": {ID: "bkgxnm", Name: "Quantum Clovera"}, + "KRYA": {ID: "c6vopr", Name: "Bangun Karya Per"}, + "KSIX": {ID: "cfl4f2", Name: "Kentanix Supra"}, + "KUAS": {ID: "c3tgqh", Name: "Ace Oldfields"}, + "LABA": {ID: "c2ekur", Name: "Green Power"}, + "LABS": {ID: "cdml7w", Name: "UBC Medical"}, + "LAJU": {ID: "c8ni2w", Name: "PT Jasa Berdikar"}, + "LAND": {ID: "bgwfxm", Name: "Trimitra"}, + "LAPD": {ID: "bkgybh", Name: "Leyand Intl"}, + "LCKM": {ID: "bkgyh7", Name: "LCK Global"}, + "LEAD": {ID: "bkgyk2", Name: "Logindo Samudra"}, + "LFLO": {ID: "c1g5f2", Name: "Imago Mulia"}, + "LIFE": {ID: "bqsrz2", Name: "MSIG Life Ins"}, + "LINK": {ID: "bkgymw", Name: "Link Net"}, + "LION": {ID: "bkgypr", Name: "Lion Metal Works"}, + "LIVE": {ID: "ccbz52", Name: "Homeco Victoria"}, + "LMAX": {ID: "caj9gh", Name: "Lupromax Pelumas"}, + "LMPI": {ID: "bkgyyc", Name: "Langgeng Makmur"}, + "LMSH": {ID: "bkgz27", Name: "Lionmesh Prima"}, + "LOPI": {ID: "cb2a2w", Name: "Logisticsplus"}, + "LPCK": {ID: "bkgz52", Name: "Lippo Cikarang"}, + "LPGI": {ID: "bkgz7w", Name: "Lippo Gen Insur"}, + "LPIN": {ID: "bkgzar", Name: "Multi Prima"}, + "LPKR": {ID: "bn99ar", Name: "Lippo Karawaci"}, + "LPLI": {ID: "bn99dm", Name: "Star Pacific"}, + "LPPF": {ID: "bkgzjc", Name: "Matahari Dept"}, + "LPPS": {ID: "bn99gh", Name: "Lenox Pasifik"}, + "LRNA": {ID: "bkgzp2", Name: "Eka Sari Lorena"}, + "LSIP": {ID: "bn99jc", Name: "PP Lonsum"}, + "LTLS": {ID: "bkgzur", Name: "Lautan Luas"}, + "LUCK": {ID: "bny8z2", Name: "Sentral Mitra In"}, + "LUCY": {ID: "c1zlh7", Name: "Lima Dua Lima"}, + "MAHA": {ID: "caem3m", Name: "Mandiri Herindo"}, + "MAIN": {ID: "bkh177", Name: "Malindo Feedmill"}, + "MANG": {ID: "cc463m", Name: "Manggung Polahra"}, + "MAPA": {ID: "bgwzm7", Name: "MAPA"}, + "MAPB": {ID: "bkh1cw", Name: "Map Boga Adiperk"}, + "MAPI": {ID: "bn98pr", Name: "Mitra Adiperkasa"}, + "MARI": {ID: "bkh1im", Name: "MARI"}, + "MARK": {ID: "bkh1lh", Name: "Mark Dynamics ID"}, + "MASB": {ID: "c2jw8m", Name: "Bank Multiarta"}, + "MAXI": {ID: "c9zd5r", Name: "Maxindo Karya"}, + "MAYA": {ID: "bkh1r7", Name: "Bank Mayapada"}, + "MBAP": {ID: "bkh1u2", Name: "Mitrabara"}, + "MBMA": {ID: "c9fe52", Name: "Merdeka Battery"}, + "MBSS": {ID: "bkh1ww", Name: "Mitrabahtera"}, + "MBTO": {ID: "bkh1zr", Name: "Martina Berto"}, + "MCAS": {ID: "bkh23m", Name: "M Cash Integrasi"}, + "MCOL": {ID: "c3apnm", Name: "Prima Andalan"}, + "MCOR": {ID: "bkh26h", Name: "CCB Indonesia"}, + "MDIA": {ID: "bkh2c7", Name: "Intermedia Cap"}, + "MDIY": {ID: "cff1lh", Name: "Daya Intiguna"}, + "MDKA": {ID: "bkh2f2", Name: "Merdeka Copper"}, + "MDKI": {ID: "bkh2hw", Name: "Emdeki Utama"}, + "MDLA": {ID: "cgbphw", Name: "Medela Potentia"}, + "MDLN": {ID: "bkh2kr", Name: "Modernland"}, + "MDRN": {ID: "bkh2nm", Name: "Modern Internasi"}, + "MEDC": {ID: "bn9a77", Name: "Medco Energi"}, + "MEDS": {ID: "c71idm", Name: "Hetzer Medical"}, + "MEGA": {ID: "bkh2tc", Name: "Bank Mega"}, + "MEJA": {ID: "ccbqh7", Name: "Pt Harta Djaya K"}, + "MENN": {ID: "c9fbu2", Name: "Menn"}, + "MERI": {ID: "che6cw", Name: "Merry Riana"}, + "MERK": {ID: "bkh2w7", Name: "Merck Indonesia"}, + "MFMI": {ID: "bkh35r", Name: "Multifiling"}, + "MGLV": {ID: "c2cba2", Name: "Panca Anugrah"}, + "MGNA": {ID: "bkh38m", Name: "Magna Investama"}, + "MGRO": {ID: "bgxdcw", Name: "Mahkota Group"}, + "MHKI": {ID: "ccwk27", Name: "Multi Hanna"}, + "MICE": {ID: "bn9afr", Name: "Multi Indocitra"}, + "MIDI": {ID: "bn9aim", Name: "Midi Utama ID"}, + "MIKA": {ID: "bkh3h7", Name: "Mitra Keluarga"}, + "MINA": {ID: "bkh427", Name: "Sanurhasta Mitra"}, + "MINE": {ID: "cg3epr", Name: "Sinar Terang"}, + "MIRA": {ID: "bkh452", Name: "Mitra Intl Rsrcs"}, + "MITI": {ID: "bkh47w", Name: "Mitra Investindo"}, + "MKAP": {ID: "ccbrar", Name: "Multikarya Asia"}, + "MKPI": {ID: "bkh4dm", Name: "Metropolitan ID"}, + "MKTR": {ID: "c7sz6h", Name: "MKTR"}, + "MLBI": {ID: "bkh4gh", Name: "Multi Bintang ID"}, + "MLIA": {ID: "bkh4jc", Name: "Mulia Industri"}, + "MLPL": {ID: "bn9alh", Name: "Multipolar"}, + "MLPT": {ID: "bkh4p2", Name: "Multipolar Tech"}, + "MMIX": {ID: "c87lz2", Name: "Multi Medika Int"}, + "MMLP": {ID: "bkh4rw", Name: "MMP"}, + "MNCN": {ID: "bkh4ur", Name: "Media Citra"}, + "MOLI": {ID: "b9fksm", Name: "Madusari Murni"}, + "MORA": {ID: "c6zya2", Name: "Moratelindo"}, + "MPIX": {ID: "ccbqmw", Name: "Mitra Pedagang"}, + "MPMX": {ID: "bkh3mw", Name: "Mitra Pinasthika"}, + "MPOW": {ID: "bkh3pr", Name: "MegaPower Makmur"}, + "MPPA": {ID: "bn9azr", Name: "Matahari Putra"}, + "MPRO": {ID: "bkh3vh", Name: "Maha Properti"}, + "MPXL": {ID: "c9ql7w", Name: "MPX Logistics"}, + "MRAT": {ID: "bkh3yc", Name: "Mustika Ratu"}, + "MREI": {ID: "bkhcur", Name: "Marein"}, + "MSIE": {ID: "cakqgh", Name: "Multisarana"}, + "MSIN": {ID: "bgy7nm", Name: "MNC Digital"}, + "MSJA": {ID: "cc466h", Name: "Multi Spunindo"}, + "MSKY": {ID: "bkhcxm", Name: "MNC Sky Vision"}, + "MSTI": {ID: "cbe9qh", Name: "Mastersys"}, + "MTDL": {ID: "bkhd1h", Name: "Metrodata Elec"}, + "MTEL": {ID: "c45acw", Name: "Mitratel"}, + "MTFN": {ID: "bgy82w", Name: "Capitalinc Inv"}, + "MTLA": {ID: "bkhd4c", Name: "MetLand"}, + "MTMH": {ID: "c5w4lh", Name: "Murni Sadar"}, + "MTPS": {ID: "bpmq9c", Name: "Meta Epsi"}, + "MTSM": {ID: "bkhda2", Name: "Metro Realty"}, + "MTWI": {ID: "bkhdfr", Name: "MTWI"}, + "MUTU": {ID: "cajgyc", Name: "Mutuagung"}, + "MYOH": {ID: "bkhcgh", Name: "Samindo Rsrcs"}, + "MYOR": {ID: "bn9bkr", Name: "Mayora Indah"}, + "MYTX": {ID: "bkhcp2", Name: "APAC Inv"}, + "NAIK": {ID: "cf6abh", Name: "Adiwarna Anugera"}, + "NANO": {ID: "c5es5r", Name: "Nanotech Indones"}, + "NASA": {ID: "bkhc52", Name: "Andalan Perkasa"}, + "NASI": {ID: "c4earw", Name: "Wahana Inti"}, + "NATO": {ID: "bor3r7", Name: "Surya Permata"}, + "NAYZ": {ID: "c8pvw7", Name: "Hassana Boga"}, + "NCKL": {ID: "c9dmnm", Name: "Trimegah Bangun"}, + "NELY": {ID: "bkhcar", Name: "Pelayaran Nelly"}, + "NEST": {ID: "cecdh7", Name: "Esta Indonesia"}, + "NETV": {ID: "c4zpnm", Name: "MDTV Media Tech"}, + "NFCX": {ID: "bh2cm7", Name: "NFC Indonesia"}, + "NICE": {ID: "cc46f2", Name: "Adhi Kartiko"}, + "NICK": {ID: "bkhdoc", Name: "Charnic Capital"}, + "NICL": {ID: "c2og3m", Name: "PAM Mineral"}, + "NIKL": {ID: "bkhdr7", Name: "Latinusa"}, + "NINE": {ID: "c87ih7", Name: "Techno9 Id Pt"}, + "NIRO": {ID: "bkhdww", Name: "City Retail Deve"}, + "NISP": {ID: "bkhdzr", Name: "Bank OCBC NISP"}, + "NOBU": {ID: "bkhe3m", Name: "Nobu Bank"}, + "NPGF": {ID: "c1mpp2", Name: "Nusa Palapa"}, + "NRCA": {ID: "bkhe9c", Name: "Nusa Raya Cipta"}, + "NSSS": {ID: "c92ssm", Name: "PT Nusantara"}, + "NTBK": {ID: "c53nim", Name: "Nusatama Berkah"}, + "NZIA": {ID: "brkv8m", Name: "Nusantara"}, + "OASA": {ID: "bkhdim", Name: "Maharaksa Biru"}, + "OBAT": {ID: "cflf77", Name: "Brigit Biofarmak"}, + "OBMD": {ID: "c4dnkr", Name: "OBM"}, + "OILS": {ID: "c3a84c", Name: "Indo Oil Perkasa"}, + "OKAS": {ID: "bkhdlh", Name: "Ancora ID Rscrs"}, + "OLIV": {ID: "c64i27", Name: "Oscar Mitra Suks"}, + "OMED": {ID: "c7t2jc", Name: "Jayamas Medica"}, + "OMRE": {ID: "bkhekr", Name: "Indo Prima Prpty"}, + "OPMS": {ID: "brkez2", Name: "Optima Prima"}, + "PACK": {ID: "c8sn2w", Name: "Abadi Nusantara"}, + "PADA": {ID: "c87qyc", Name: "Personel Alih"}, + "PADI": {ID: "bkheqh", Name: "Minna Padi"}, + "PALM": {ID: "bkhetc", Name: "Provident Invest"}, + "PAMG": {ID: "bqrcxm", Name: "Bima Sakti"}, + "PANI": {ID: "bh3952", Name: "Pratama Abadi"}, + "PANR": {ID: "bkhez2", Name: "Panorama Sentra"}, + "PANS": {ID: "bkhf2w", Name: "Panin Sekuritas"}, + "PART": {ID: "cdlta2", Name: "Cipta Perdana"}, + "PBID": {ID: "bkhf5r", Name: "Panca Budi"}, + "PBRX": {ID: "bkhf8m", Name: "Pan Brothers"}, + "PBSA": {ID: "bkhfbh", Name: "PBS"}, + "PCAR": {ID: "bkhfec", Name: "Prima Cakrawala"}, + "PDES": {ID: "bkhfh7", Name: "Destinasi Tirta"}, + "PDPP": {ID: "c7wzkr", Name: "Primadaya"}, + "PEGE": {ID: "bkhfk2", Name: "Panca Kapital"}, + "PEHA": {ID: "boeyr7", Name: "Phapros"}, + "PEVE": {ID: "c8lqu2", Name: "Penta Valent"}, + "PGAS": {ID: "bn9ch7", Name: "PGN"}, + "PGEO": {ID: "c8wmc7", Name: "Pertamina Enrgy"}, + "PGJO": {ID: "btf6im", Name: "Bahtera Bumi"}, + "PGLI": {ID: "bkhfpr", Name: "Graha Lestari"}, + "PGUN": {ID: "bv2tpr", Name: "Pradiksi"}, + "PICO": {ID: "bkhfvh", Name: "Pelangi Indah"}, + "PIPA": {ID: "c9cy5r", Name: "Multi Makmur"}, + "PJAA": {ID: "bkhfyc", Name: "Jaya Ancol"}, + "PKPK": {ID: "bkhh1h", Name: "Perdana Karya"}, + "PLAN": {ID: "bvsmnm", Name: "PPJ"}, + "PLIN": {ID: "bkhh77", Name: "Plaza ID Realty"}, + "PMJS": {ID: "bt81mw", Name: "Putra Mandiri"}, + "PMMP": {ID: "bx3rtc", Name: "Panca Mitra Mult"}, + "PMUI": {ID: "che6fr", Name: "Prima Multi"}, + "PNBN": {ID: "bn9d27", Name: "PaninBank"}, + "PNBS": {ID: "bn9d52", Name: "Bank Panin Dubai"}, + "PNGO": {ID: "bvilbh", Name: "Pinago Utama"}, + "PNIN": {ID: "bkhhfr", Name: "Paninvest"}, + "PNLF": {ID: "bn9d7w", Name: "Panin Financial"}, + "PNSE": {ID: "bkhhlh", Name: "Pudjiadi and Son"}, + "POLA": {ID: "bnw452", Name: "Pool Advista Fin"}, + "POLI": {ID: "bokeh7", Name: "Pollux Hotels"}, + "POLU": {ID: "bqiq5r", Name: "Golden Flower"}, + "POLY": {ID: "bkhg27", Name: "APAC Fibers"}, + "PORT": {ID: "bkhg7w", Name: "Nusantara"}, + "POWR": {ID: "bn9dar", Name: "Cikarang Lstndo"}, + "PPGL": {ID: "bv7ka2", Name: "Prima Globalindo"}, + "PPRE": {ID: "bkhgdm", Name: "PP Presisi"}, + "PPRI": {ID: "caj5xm", Name: "Paperocks"}, + "PPRO": {ID: "bkhggh", Name: "PP Properti"}, + "PRAY": {ID: "c7svf2", Name: "Famon Awal Bros"}, + "PRDA": {ID: "bkhgm7", Name: "Prodia Widyahusa"}, + "PRIM": {ID: "bh3bnm", Name: "Royal Prima"}, + "PSAB": {ID: "bkhgp2", Name: "J Resources Asia"}, + "PSDN": {ID: "bkhgrw", Name: "Prasidha Aneka"}, + "PSGO": {ID: "bssb7w", Name: "Palma Serasih"}, + "PSKT": {ID: "bkhgur", Name: "Red Planet"}, + "PSSI": {ID: "bkhgxm", Name: "IMC Pelita"}, + "PTBA": {ID: "bn9i4c", Name: "Bukit Asam"}, + "PTDU": {ID: "bwzbim", Name: "Djasa Ubersakti"}, + "PTIS": {ID: "bkhif2", Name: "Indo Straits"}, + "PTMP": {ID: "c8zh3m", Name: "Mitra Pack"}, + "PTMR": {ID: "cese7w", Name: "PT Master Print"}, + "PTPP": {ID: "bkhihw", Name: "PP (Persero)"}, + "PTPS": {ID: "cb26pr", Name: "Pulau Subur"}, + "PTPW": {ID: "btowc7", Name: "Pratama Widya"}, + "PTRO": {ID: "bkhinm", Name: "Petrosea"}, + "PTSN": {ID: "bkhiqh", Name: "Sat Nusapersada"}, + "PTSP": {ID: "bkhitc", Name: "Pioneerindo"}, + "PUDP": {ID: "bkhiw7", Name: "Pudjiadi Prestig"}, + "PURA": {ID: "btjqcw", Name: "Puratrans"}, + "PURI": {ID: "bvmvlh", Name: "Puri Glo Sukses"}, + "PWON": {ID: "bkhiz2", Name: "Pakuwon Jati"}, + "PYFA": {ID: "bkhj2w", Name: "Pyridam Farma"}, + "PZZA": {ID: "bh3dar", Name: "Sarimelati"}, + "RAAM": {ID: "c9pg7w", Name: "Tripar Multi"}, + "RAFI": {ID: "c6zy1h", Name: "Sari Kreasi Boga"}, + "RAJA": {ID: "bkhhu2", Name: "Rukun Raharja"}, + "RALS": {ID: "bn9iim", Name: "Ramayana Lestari"}, + "RANC": {ID: "bkhhzr", Name: "Supra Boga"}, + "RATU": {ID: "cfl3oc", Name: "Raharja Energi"}, + "RBMS": {ID: "bkhi3m", Name: "Ristia Bintang"}, + "RCCC": {ID: "c6y4ur", Name: "Utama Radar"}, + "RDTX": {ID: "bkhi6h", Name: "Roda Vivatex"}, + "REAL": {ID: "bsxdzr", Name: "Repower Asia"}, + "RELF": {ID: "ca43u2", Name: "Graha Mitra"}, + "RELI": {ID: "bkhi9c", Name: "Reliance Skrt ID"}, + "RGAS": {ID: "cbe25r", Name: "Kian Santang"}, + "RICY": {ID: "bkhj5r", Name: "Ricky Putra"}, + "RIGS": {ID: "bkhj8m", Name: "Rig Tenders ID"}, + "RISE": {ID: "bh3par", Name: "Jaya Sukses"}, + "RMKE": {ID: "c4bf52", Name: "RMK Energy"}, + "RMKO": {ID: "cafyoc", Name: "Royaltama Mulia"}, + "ROCK": {ID: "bvpnqh", Name: "Rockfields"}, + "RODA": {ID: "bkhkur", Name: "Pikko Land Dev"}, + "RONY": {ID: "bua477", Name: "Aracord"}, + "ROTI": {ID: "bkhkxm", Name: "Nippon Indosari"}, + "RSCH": {ID: "caoza2", Name: "Charlie Hospital"}, + "RSGK": {ID: "c3atf2", Name: "Kedoya Adyaraya"}, + "RUIS": {ID: "bkhjh7", Name: "Radiant Utama"}, + "RUNS": {ID: "c3athw", Name: "Global Sukses"}, + "SAFE": {ID: "bh4khw", Name: "Steady Safe"}, + "SAGE": {ID: "c8ztkr", Name: "Saptausaha Gemil"}, + "SAME": {ID: "bkhl1h", Name: "Sarana Meditama"}, + "SAMF": {ID: "bu77h7", Name: "Saraswanti"}, + "SAPX": {ID: "bkhl4c", Name: "Satria Antaran"}, + "SATU": {ID: "bnmpyc", Name: "Kota Satu"}, + "SBMA": {ID: "c3apw7", Name: "Surya Biru Murni"}, + "SCCO": {ID: "bkhl77", Name: "Sucaco"}, + "SCMA": {ID: "bkhla2", Name: "Surya Citra"}, + "SCNP": {ID: "bvpljc", Name: "SCNP"}, + "SDMU": {ID: "bkhlcw", Name: "Sidomulyo"}, + "SDPC": {ID: "bkhlfr", Name: "Millennium P Int"}, + "SDRA": {ID: "bkhlim", Name: "Bk Woori Saudara"}, + "SEMA": {ID: "c4s94c", Name: "Semacom"}, + "SFAN": {ID: "bqigoc", Name: "Surya Fajar Cap"}, + "SGER": {ID: "bvdqa2", Name: "SGE"}, + "SGRO": {ID: "bn9j9c", Name: "Prime Agri Rsrcs"}, + "SHID": {ID: "bkhlww", Name: "Hotel Sahid Jaya"}, + "SHIP": {ID: "bkhlzr", Name: "Sillo Maritime"}, + "SICO": {ID: "c5tatc", Name: "Sigma Energy"}, + "SIDO": {ID: "bkhm6h", Name: "Sido Muncul"}, + "SILO": {ID: "bn9jnm", Name: "Siloam Intl"}, + "SIMP": {ID: "bkhmf2", Name: "Salim Ivomas"}, + "SINI": {ID: "bsn27w", Name: "Singaraja Putra"}, + "SIPD": {ID: "bkhpzr", Name: "Sreeya Sewu Indo"}, + "SKBM": {ID: "bkhq3m", Name: "Sekar Bumi"}, + "SKLT": {ID: "bkhq6h", Name: "Sekar Laut"}, + "SKRN": {ID: "bkhqc7", Name: "Superkrane Mitra"}, + "SLIS": {ID: "bs6kh7", Name: "Gaya Abadi"}, + "SMAR": {ID: "bkhrmw", Name: "SMART ID"}, + "SMBR": {ID: "bn9lar", Name: "Semen Baturaja"}, + "SMCB": {ID: "bn9ldm", Name: "Solusi Bangun In"}, + "SMDM": {ID: "bkhryc", Name: "Suryamas Duta"}, + "SMDR": {ID: "bkhs27", Name: "Samudera ID"}, + "SMGA": {ID: "cc93tc", Name: "Sumber Mineral"}, + "SMGR": {ID: "bn9lgh", Name: "Semen Indonesia"}, + "SMIL": {ID: "c9r7oc", Name: "Sarana Mitra"}, + "SMKL": {ID: "bqrfh7", Name: "Satyamitra Kemas"}, + "SMKM": {ID: "c5gqk2", Name: "Sumber Mas"}, + "SMLE": {ID: "cc45oc", Name: "Sinergi Multi"}, + "SMMA": {ID: "bkhs7w", Name: "Sinar Mas Multi"}, + "SMMT": {ID: "bkhsar", Name: "Golden Eagle"}, + "SMRA": {ID: "bn9ljc", Name: "Summarecon Agung"}, + "SMSM": {ID: "bkhsjc", Name: "Selamat Sempurna"}, + "SNLK": {ID: "c1c3ur", Name: "Sunter Lakeside"}, + "SOCI": {ID: "bkhmkr", Name: "Soechi Lines"}, + "SOFA": {ID: "bv1prw", Name: "Solusi Envrmt"}, + "SOHO": {ID: "bvpia2", Name: "Soho Global Hlth"}, + "SOLA": {ID: "cd3j77", Name: "Xolare RCR"}, + "SONA": {ID: "bkhmqh", Name: "Sona Topas Tour"}, + "SOSS": {ID: "borblh", Name: "SOS"}, + "SOTS": {ID: "bo5fk2", Name: "SMK"}, + "SOUL": {ID: "c8hmu2", Name: "Mitra Tirta Buwa"}, + "SPMA": {ID: "bkhqhw", Name: "Suparma"}, + "SPRE": {ID: "cdllww", Name: "Soraya Berjaya"}, + "SPTO": {ID: "bh4tpr", Name: "Surya Pertiwi"}, + "SQMI": {ID: "bkhqkr", Name: "Renuka Coalindo"}, + "SRAJ": {ID: "bkhqnm", Name: "Sejahteraraya"}, + "SRSN": {ID: "bkhqw7", Name: "Indo Acidatama"}, + "SRTG": {ID: "bkhqz2", Name: "Saratoga Investa"}, + "SSIA": {ID: "bkhr2w", Name: "Surya Semesta"}, + "SSMS": {ID: "bkhr5r", Name: "Sawit Sumbermas"}, + "SSTM": {ID: "bkhr8m", Name: "Sunson Textile"}, + "STAA": {ID: "c5grjc", Name: "STA Resources"}, + "STAR": {ID: "bkhrbh", Name: "Buana Artha"}, + "STRK": {ID: "cb1rcw", Name: "Lovina Beach"}, + "STTP": {ID: "bkhrec", Name: "Siantar Top"}, + "SULI": {ID: "bn9l52", Name: "SLJ Global"}, + "SUNI": {ID: "c8irzr", Name: "Sunindo Pratama"}, + "SUPR": {ID: "bkhsp2", Name: "Solusi Tunas"}, + "SURE": {ID: "bkhsrw", Name: "Super Energy"}, + "SURI": {ID: "cbriz2", Name: "Maja Agung"}, + "SWAT": {ID: "bh4xyc", Name: "Sriwahana"}, + "SWID": {ID: "c6q9ww", Name: "Saraswanti"}, + "TALF": {ID: "bkhsxm", Name: "Tunas Alfin"}, + "TAMA": {ID: "btnwec", Name: "Lancartama"}, + "TAMU": {ID: "bkht1h", Name: "Pelayaran"}, + "TAPG": {ID: "c1pha2", Name: "Triputra Agro"}, + "TARA": {ID: "bkht4c", Name: "Agung Semesta"}, + "TAXI": {ID: "bkhta2", Name: "Express Trans ID"}, + "TAYS": {ID: "c4bfdm", Name: "Jaya Swarasa"}, + "TBIG": {ID: "bkhtcw", Name: "Tower Bersama"}, + "TBLA": {ID: "bn9m77", Name: "Tunas Baru"}, + "TBMS": {ID: "bkhtim", Name: "Tembaga Mulia"}, + "TCID": {ID: "bkhtlh", Name: "Mandom ID"}, + "TCPI": {ID: "bh55nm", Name: "Transcoal Pacifi"}, + "TEBE": {ID: "bsqufr", Name: "Dana Brata Luhur"}, + "TELE": {ID: "bkhtr7", Name: "Omni Inovasi"}, + "TFAS": {ID: "brgc52", Name: "Telefast"}, + "TFCO": {ID: "bkhtzr", Name: "Tifico Fiber ID"}, + "TGKA": {ID: "bkhu6h", Name: "Tigaraksa Satria"}, + "TGRA": {ID: "bkhu9c", Name: "Terregra Asia"}, + "TGUK": {ID: "ca99f2", Name: "Platinum Wahab"}, + "TIFA": {ID: "bkhuc7", Name: "KDB Tifa Finance"}, + "TINS": {ID: "bn9o2w", Name: "Timah"}, + "TIRA": {ID: "bkhunm", Name: "Tira Austenite"}, + "TIRT": {ID: "bkhuqh", Name: "Tirta Mahakam"}, + "TKIM": {ID: "bn9o5r", Name: "Tjiwi Kimia"}, + "TLDN": {ID: "c5tiqh", Name: "Teladan Prima"}, + "TLKM": {ID: "bn9o8m", Name: "Telkom Indonesia"}, + "TMAS": {ID: "bn9oec", Name: "Temas"}, + "TMPO": {ID: "bkhv8m", Name: "Tempo Inti Media"}, + "TNCA": {ID: "bh5cim", Name: "Trimuda Nuansa"}, + "TOBA": {ID: "bkhvk2", Name: "TBS Energi Utama"}, + "TOOL": {ID: "c71edm", Name: "Rohartindo"}, + "TOSK": {ID: "ccbqk2", Name: "Topindo Solusi"}, + "TOTL": {ID: "bn9ok2", Name: "Total Bangun"}, + "TOTO": {ID: "bkhvsm", Name: "Surya Toto ID"}, + "TOWR": {ID: "bn9omw", Name: "Sarana Menara"}, + "TPIA": {ID: "bkhvyc", Name: "Chandra"}, + "TPMA": {ID: "bkhw27", Name: "Trans Power"}, + "TRGU": {ID: "c6qatc", Name: "Cerestar ID"}, + "TRIM": {ID: "bki2lh", Name: "Trimegah Sekurit"}, + "TRIN": {ID: "btfcbh", Name: "Perintis Triniti"}, + "TRIS": {ID: "bki2oc", Name: "Trisula Intl"}, + "TRJA": {ID: "bviktc", Name: "Transkon Jaya"}, + "TRON": {ID: "c8zthw", Name: "Teknologi Karya"}, + "TRST": {ID: "bki2u2", Name: "Trias Sentosa"}, + "TRUE": {ID: "c2cfa2", Name: "Triniti Dinamik"}, + "TRUK": {ID: "bh7r6h", Name: "Guna Timur Raya"}, + "TRUS": {ID: "bki2zr", Name: "Trust Finance ID"}, + "TSPC": {ID: "bki33m", Name: "Tempo Scan ID"}, + "TUGU": {ID: "bh72cw", Name: "Asuransi Tugu"}, + "TYRE": {ID: "c9pfpr", Name: "King Tire"}, + "UANG": {ID: "bv1qlh", Name: "Pakuan"}, + "UCID": {ID: "bt7nnm", Name: "Uni-Charm"}, + "UDNG": {ID: "cbazxm", Name: "Agro Bahari Nusa"}, + "UFOE": {ID: "bxjur7", Name: "Damai Sejahtera"}, + "ULTJ": {ID: "bki36h", Name: "Ultrajaya Milk"}, + "UNIC": {ID: "bki39c", Name: "Unggul Indah"}, + "UNIQ": {ID: "bzv6mw", Name: "Ulima Nitra"}, + "UNSP": {ID: "bn9pjc", Name: "Bakrie Sumatera"}, + "UNTD": {ID: "ccanww", Name: "Terang Dunia Int"}, + "UNTR": {ID: "bn9pm7", Name: "United Tractors"}, + "UNVR": {ID: "bn9pp2", Name: "Unilever ID"}, + "URBN": {ID: "bo5fsm", Name: "Urban Jakarta"}, + "UVCR": {ID: "c2ruww", Name: "Trimegah Karya"}, + "VAST": {ID: "c8sn8m", Name: "Vastland Id Pt"}, + "VERN": {ID: "cesblh", Name: "Verona Indah"}, + "VICI": {ID: "bx3pp2", Name: "Victoria Care"}, + "VICO": {ID: "bki3nm", Name: "Victoria Investa"}, + "VINS": {ID: "bki3qh", Name: "Victoria Insur"}, + "VISI": {ID: "cchqzr", Name: "Satu Visi Putra"}, + "VIVA": {ID: "bki45r", Name: "Visi Media Asia"}, + "VKTR": {ID: "ca2e9c", Name: "VKTR"}, + "VOKS": {ID: "bki4bh", Name: "Voksel Electric"}, + "VRNA": {ID: "bki4k2", Name: "PT Mizuho Leasin"}, + "VTNY": {ID: "c89txm", Name: "Venteny Fortuna"}, + "WAPO": {ID: "bki4mw", Name: "Wahana Pronat"}, + "WEGE": {ID: "bki4pr", Name: "WIKA Gedung"}, + "WEHA": {ID: "bki4sm", Name: "WEHA Transportas"}, + "WGSH": {ID: "c4bevh", Name: "Wira Global"}, + "WICO": {ID: "bki76h", Name: "Wicaksana Intl"}, + "WIDI": {ID: "ca9dkr", Name: "PT Widiant Jaya"}, + "WIFI": {ID: "bx86dm", Name: "Solusi Sinergi"}, + "WIIM": {ID: "bki79c", Name: "Wismilak Inti"}, + "WIKA": {ID: "bn9q77", Name: "Wijaya Karya"}, + "WINE": {ID: "c8isf2", Name: "Hatten Bali"}, + "WINR": {ID: "c5yu77", Name: "Winner Nusantara"}, + "WINS": {ID: "bn9qa2", Name: "Wintermar Offshr"}, + "WIRG": {ID: "c5o9z2", Name: "Wir Asia"}, + "WMUU": {ID: "bxjkec", Name: "Widodo Makmur"}, + "WOMF": {ID: "bki7hw", Name: "WOM Finance"}, + "WOOD": {ID: "bki7kr", Name: "Integra Indocabi"}, + "WOWS": {ID: "bsnwf2", Name: "Ginting Jaya"}, + "WSBP": {ID: "bki7z2", Name: "Waskita Beton"}, + "WTON": {ID: "bn9r9c", Name: "WIKA Beton"}, + "YELO": {ID: "bnehec", Name: "Yelooo Integra"}, + "YOII": {ID: "cfl49c", Name: "Asuransi Digital"}, + "YPAS": {ID: "bki8bh", Name: "Yanaprima Hasta"}, + "YULE": {ID: "bki7nm", Name: "Yulie Sekuritas"}, + "YUPI": {ID: "cgaarw", Name: "Yupi Indo Jelly"}, + "ZATA": {ID: "c7x3im", Name: "Bersama Zatta Ja"}, + "ZBRA": {ID: "bhacdm", Name: "Dosni Roha"}, + "ZINC": {ID: "bki7qh", Name: "Kapuas Prima"}, + "ZONE": {ID: "bo5fyc", Name: "Mega Perintis"}, + "ZYRX": {ID: "c1c1kr", Name: "Zyrexindo Mandir"}, +} + +// IDXIDToTicker maps all MSN IDs (898) back to ticker symbols +// Some IDs may map to the same ticker (e.g., SOUL has multiple IDs) +var IDXIDToTicker = map[string]string{ + "cfatz2": "AADI", + "bn8zk2": "AALI", + "bkf9ec": "ABBA", + "bkf9h7": "ABDA", + "bkf9k2": "ABMM", + "bn8zyc": "ACES", + "cc48gh": "ACRO", + "bkf9pr": "ACST", + "c59mf2": "ADCP", + "bkf9sm": "ADES", + "bn9127": "ADHI", + "bkf9yc": "ADMF", + "bn9152": "ADMG", + "c4pl52": "ADMR", + "bn917w": "ADRO", + "carobh": "AEGS", + "bsu9xm": "AGAR", + "bkfa7w": "AGII", + "bkfaar": "AGRO", + "bkfadm": "AGRS", + "bkfagh": "AHAP", + "bkfajc": "AIMS", + "bkfam7": "AISA", + "bkfctc": "AKKU", + "bkfcw7": "AKPI", + "bkfcz2": "AKRA", + "bkfd2w": "AKSI", + "bkfd5r": "ALDO", + "ccanzr": "ALII", + "bkfd8m": "ALKA", + "bkfdbh": "ALMI", + "bkfdec": "ALTO", + "bkfchw": "AMAG", + "btysar": "AMAN", + "btf6a2": "AMAR", + "bkfckr": "AMFG", + "bkfcnm": "AMIN", + "ca993m": "AMMN", + "c6yaw7": "AMMS", + "btfbw7": "AMOR", + "bkfcqh": "AMRT", + "bgrwf2": "ANDI", + "bkfem7": "ANJT", + "bn8zmw": "ANTM", + "bkferw": "APEX", + "bkfeur": "APIC", + "bkfexm": "APII", + "bkff1h": "APLI", + "bkff4c": "APLN", + "c2il1h": "ARCI", + "ccsom7": "AREA", + "bkff77": "ARGO", + "bkffa2": "ARII", + "bqtwtc": "ARKA", + "c6qaqh": "ARKO", + "bkfffr": "ARNA", + "bkffim": "ARTA", + "bkffoc": "ARTO", + "bkffzr": "ASBI", + "bkfg3m": "ASDM", + "bkfg6h": "ASGR", + "c6a3yc": "ASHA", + "bn91gh": "ASII", + "bkfgc7": "ASJT", + "c4xl52": "ASLC", + "cc41ww": "ASLI", + "bkfgf2": "ASMI", + "btr8pr": "ASPI", + "chbyjc": "ASPR", + "bkfghw": "ASRI", + "bkfgkr": "ASRM", + "bkfgnm": "ASSA", + "bwzup2": "ATAP", + "bkffr7": "ATIC", + "ccwjvh": "ATLA", + "bkffu2": "AUTO", + "c4bepr": "AVIA", + "c9f9yc": "AWAN", + "c6v4pr": "AXIO", + "cbkfkr": "AYAM", + "btox8m": "AYLS", + "bkfgqh": "BABP", + "carnqh": "BABY", + "bkfgtc": "BACA", + "ccbzar": "BAIK", + "bkfgw7": "BAJA", + "bkfgz2": "BALI", + "bxjrc7": "BANK", + "bkfh2w": "BAPA", + "breeur": "BAPI", + "bkflyc": "BATA", + "cddzvh": "BATR", + "c4xpm7": "BAUT", + "bkfm27": "BAYU", + "bn91jc": "BBCA", + "bkfm7w": "BBHI", + "bkfmar": "BBKP", + "bkfmdm": "BBLD", + "bkfmgh": "BBMD", + "bn91m7": "BBNI", + "bn91p2": "BBRI", + "bkfmrw": "BBRM", + "bvpgvh": "BBSI", + "bu9cqh": "BBSS", + "bkfmur": "BBTN", + "bkfmxm": "BBYB", + "bkfn1h": "BCAP", + "bgt4rw": "BCIC", + "bkfn4c": "BCIP", + "c8zmyc": "BDKR", + "bn91rw": "BDMN", + "bzv7ar": "BEBS", + "bokavh": "BEEF", + "c8if77": "BEER", + "bkfna2": "BEKS", + "c7svnm": "BELI", + "bkfncw": "BELL", + "btyakr": "BESS", + "bkfh5r": "BEST", + "bkfh8m": "BFIN", + "bkfhbh": "BGTG", + "bua6k2": "BHAT", + "bkfhec": "BHIT", + "c5iucw": "BIKE", + "bkfkz2": "BIMA", + "bn92u2": "BINA", + "c45tp2": "BINO", + "bn92ww": "BIPI", + "bkfl8m": "BIPP", + "bkflbh": "BIRD", + "bn92zr": "BISI", + "bkflh7": "BJBR", + "bn936h": "BJTM", + "bkflmw": "BKDP", + "bkflpr": "BKSL", + "bkflsm": "BKSW", + "cdlvh7": "BLES", + "chc7m7": "BLOG", + "bgt5u2": "BLTA", + "bkflvh": "BLTZ", + "bqrgar": "BLUE", + "bkfoqh": "BMAS", + "c8isqh": "BMBL", + "c2md4c": "BMHS", + "bn939c": "BMRI", + "bkfow7": "BMSR", + "bkfoz2": "BMTR", + "bkfp2w": "BNBA", + "bn93c7": "BNBR", + "bkfp8m": "BNGA", + "bn93f2": "BNII", + "bn93hw": "BNLI", + "cf4yu2": "BOAT", + "c3wioc": "BOBA", + "bkfph7": "BOGA", + "bqe3p2": "BOLA", + "bkfpk2": "BOLT", + "bkfppr": "BPFI", + "bkfpsm": "BPII", + "bgth1h": "BPTR", + "bkfpvh": "BRAM", + "cb1ra2": "BREN", + "bgtha2": "BRIS", + "bn924c": "BRMS", + "bkfq27": "BRNA", + "bn9277": "BRPT", + "cflfa2": "BRRC", + "c7svhw": "BSBK", + "bkfq7w": "BSDE", + "bn92a2": "BSIM", + "c4is3m": "BSML", + "bkfqdm": "BSSR", + "bkfqgh": "BSWD", + "bkfqjc": "BTEK", + "bkfqp2": "BTON", + "bkfqrw": "BTPN", + "bgtwjc": "BTPS", + "c71dyc": "BUAH", + "bkfqur": "BUDI", + "c2wgkr": "BUKA", + "bkfqxm": "BUKK", + "bkfnu2": "BULL", + "bn92fr": "BUMI", + "bkfnzr": "BUVA", + "bkfo3m": "BVIC", + "bn92im": "BWPT", + "bn92lh": "BYAN", + "bne4c7": "CAKK", + "bkfoc7": "CAMP", + "bkfof2": "CANI", + "btysdm": "CARE", + "bkfohw": "CARS", + "bkfokr": "CASA", + "bufc7w": "CASH", + "bkfonm": "CASS", + "cflejc": "CBDK", + "c8ipmw": "CBPE", + "c8ifww": "CBRE", + "c7sxgh": "CBUT", + "bqh6ar": "CCSI", + "chdaw7": "CDIA", + "bkfkqh": "CEKA", + "bkfktc": "CENT", + "bkfkw7": "CFIN", + "cc45zr": "CGAS", + "chddzr": "CHEK", + "c6qahw": "CHEM", + "c8qlmw": "CHIP", + "bkfnfr": "CINT", + "bkfnim": "CITA", + "bjs6w7": "CITY", + "bona52": "CLAY", + "bkfnoc": "CLEO", + "bkfnr7": "CLPI", + "bn93kr": "CMNP", + "c3bxcw": "CMNT", + "bkgku2": "CMPP", + "c4b81h": "CMRY", + "bkgkzr": "CNKO", + "cagkkr": "CNMA", + "c78lp2": "COAL", + "bpfy1h": "COCO", + "chc7p2": "COIN", + "bn93nm": "CPIN", + "bkgl9c": "CPRO", + "c71ep2": "CRAB", + "ca9dnm": "CRSN", + "bn93z2": "CSAP", + "bkglf2": "CSIS", + "bua4a2": "CSMI", + "btf6cw": "CSRA", + "bkglhw": "CTBN", + "bn942w": "CTRA", + "bkglnm": "CTTH", + "c8ztf2": "CUAN", + "caj9jc": "CYBR", + "cf3dr7": "DAAZ", + "btov1h": "DADA", + "bkglqh": "DART", + "cd3e27": "DATA", + "bkgltc": "DAYA", + "bxbr3m": "DCII", + "bkglw7": "DEFI", + "c461k2": "DEPO", + "bn93qh": "DEWA", + "c6t4oc": "DEWI", + "bkgm2w": "DFAM", + "bkgm5r": "DGIK", + "bxbs2w": "DGNS", + "cfl46h": "DGWG", + "bguqr7": "DIGI", + "bn93tc": "DILD", + "bny8f2": "DIVA", + "bkgmbh": "DKFT", + "cgo8rw": "DKHH", + "bkgn52": "DLTA", + "bkgn7w": "DMAS", + "bsh6z2": "DMMX", + "bti2qh": "DMND", + "bkgnar": "DNAR", + "bkgndm": "DNET", + "bkgngh": "DOID", + "c9pg52": "DOOH", + "cecd8m": "DOSS", + "bkgnjc": "DPNS", + "bkgnm7": "DPUM", + "c4is9c": "DRMA", + "bkgnp2": "DSFI", + "bkgnrw": "DSNG", + "bn94ec": "DSSA", + "bn94h7": "DUTI", + "bkgmsm": "DVLA", + "bkgmvh": "DWGL", + "bkgmyc": "DYAN", + "bqrjmw": "EAST", + "bkgn27": "ECII", + "bxmmkr": "EDGE", + "bkgmec": "EKAD", + "c8ifcw": "ELIT", + "c71dh7": "ELPI", + "bkgmh7": "ELSA", + "bn94k2": "ELTY", + "ci4x5r": "EMAS", + "bkgo77": "EMDE", + "bkgocw": "EMTK", + "c53nww": "ENAK", + "bkgofr": "ENRG", + "bvqna2": "ENZO", + "buzekr": "EPAC", + "bkgoim": "EPMT", + "bkgolh": "ERAA", + "cakb4c": "ERAL", + "bkgooc": "ERTX", + "bsq1dm": "ESIP", + "bkgnxm": "ESSA", + "btxltc": "ESTA", + "bkgo1h": "ESTI", + "c6zylh": "EURO", + "bn94pr": "EXCL", + "bx7mww": "FAPA", + "bkgor7": "FAST", + "bkgou2": "FASW", + "bgva7w": "FILM", + "c1paqh": "FIMP", + "bkgozr": "FIRE", + "bkgp3m": "FISH", + "bqcw2w": "FITT", + "c2p22w": "FLMC", + "bkgp9c": "FMII", + "caj9ar": "FOLK", + "bohgur": "FOOD", + "cghp7w": "FORE", + "bkgpc7": "FORU", + "bkgphw": "FPNI", + "bqrhww": "FUJI", + "c8xc2w": "FUTR", + "c8no4c": "FWCT", + "bkgpnm": "GDST", + "bkgpqh": "GDYR", + "bkgptc": "GEMA", + "bkgpw7": "GEMS", + "bn94vh": "GGRM", + "brivww": "GGRP", + "bkgq2w": "GHON", + "bkgq5r": "GIAA", + "bn9527": "GJTL", + "bgveur": "GLOB", + "bt94fr": "GLVA", + "bkgqbh": "GMFI", + "bkgqk2": "GMTD", + "bkgqmw": "GOLD", + "cdml52": "GOLF", + "bkgqsm": "GOOD", + "c5tv8m": "GOTO", + "bkgqvh": "GPRA", + "c39qp2": "GPSO", + "cakam7": "GRIA", + "cc65vh": "GRPH", + "ca9bar": "GRPM", + "bkgqec": "GSMF", + "bkgqh7": "GTBO", + "c9a7c7": "GTRA", + "c3atkr": "GTSI", + "c6ybbh": "GULA", + "cdm6oc": "GUNA", + "bkgqyc": "GWSA", + "bkgr27": "GZCO", + "bkgr52": "HADE", + "c3839c": "HAIS", + "c9c9z2": "HAJJ", + "c8snbh": "HALO", + "c6vxxm": "HATM", + "caj5ur": "HBAT", + "bkgr7w": "HDFA", + "bqsr9c": "HDIT", + "bgvigh": "HEAL", + "bkgrdm": "HELI", + "bkgrgh": "HERO", + "bn95ar": "HEXA", + "cfl3r7": "HGII", + "c8xfhw": "HILL", + "bkgrm7": "HITS", + "bn95jc": "HMSP", + "bkgrur": "HOKI", + "bvpl7w": "HOMI", + "c24y52": "HOPE", + "bpplhw": "HRME", + "bkgs4c": "HRTA", + "bn957w": "HRUM", + "caj9p2": "HUMI", + "ccbzjc": "HYGN", + "bkgsa2": "IATA", + "bkgscw": "IBFN", + "c5z1vh": "IBOS", + "bkgsfr": "IBST", + "bkgsim": "ICBP", + "bkgslh": "ICON", + "c3at9c": "IDEA", + "bkgsoc": "IDPR", + "bt26w7": "IFII", + "bsua4c": "IFSH", + "bkgsr7": "IGAR", + "bkgsww": "IKAI", + "btov4c": "IKAN", + "bkgszr": "IKBI", + "cbetnm": "IKPM", + "bkguk2": "IMAS", + "bkgumw": "IMJS", + "bkgupr": "IMPC", + "bkguvh": "INAI", + "bkguyc": "INCF", + "bkgv27": "INCI", + "bn96cw": "INCO", + "bn96fr": "INDF", + "btf6ww": "INDO", + "bkgvar": "INDR", + "bkgvdm": "INDS", + "bkgvgh": "INDX", + "bkgvjc": "INDY", + "caehu2": "INET", + "bn96im": "INKP", + "bqtw6h": "INOV", + "bkgttc": "INPC", + "bkgtw7": "INPP", + "bkgtz2": "INPS", + "bkgu2w": "INRU", + "bkgu5r": "INTA", + "bkgu8m": "INTD", + "bn95m7": "INTP", + "cb1mar": "IOTF", + "c2ip4c": "IPAC", + "bgvlmw": "IPCC", + "bkguec": "IPCM", + "bkguh7": "IPOL", + "bqrdim": "IPTV", + "bs6n9c": "IRRA", + "c8ptdm": "IRSX", + "c87m5r": "ISAP", + "bn95p2": "ISAT", + "cdmkyc": "ISEA", + "bn95rw": "ISSP", + "bqptyc": "ITIC", + "bkgt9c": "ITMA", + "bn95ur": "ITMG", + "c6yb2w": "JARR", + "bpztur": "JAST", + "c9pikr": "JATI", + "bkgtf2": "JAWA", + "bp3fkr": "JAYA", + "bkgthw": "JECC", + "bkgtkr": "JGLE", + "bkgtnm": "JIHD", + "bn95xm": "JKON", + "bkgwcw": "JMAS", + "bkgwfr": "JPFA", + "bkgwim": "JRPT", + "bn964c": "JSMR", + "bkgwr7": "JSPT", + "bkgwu2": "JTPE", + "bkgvp2": "KAEF", + "cg3ah7": "KAQI", + "bkgvrw": "KARW", + "bu8obh": "KBAG", + "bkgvur": "KBLI", + "bkgvxm": "KBLM", + "bkgw1h": "KBLV", + "bkgwww": "KDSI", + "c7sxar": "KDTN", + "brdma2": "KEEN", + "bss8im": "KEJU", + "c7x3cw": "KETR", + "bgw8lh": "KIAS", + "bkgwzr": "KICI", + "bkgx3m": "KIJA", + "c8v3jc": "KING", + "bkgx6h": "KINO", + "bkgx9c": "KIOS", + "bqoa1h": "KJEN", + "c71dsm": "KKES", + "bkgxqh": "KKGI", + "c9zg9c": "KLAS", + "bn96u2": "KLBF", + "c71ear": "KLIN", + "bvpjz2": "KMDS", + "bkgxw7": "KMTR", + "bkgxz2": "KOBX", + "cazma2": "KOCI", + "bkgy2w": "KOIN", + "cb2m5r": "KOKA", + "bkgy5r": "KONI", + "bkgy8m": "KOPI", + "bqspm7": "KOTA", + "bkgxf2": "KPIG", + "bn96zr": "KRAS", + "bkgxnm": "KREN", + "c6vopr": "KRYA", + "cfl4f2": "KSIX", + "c3tgqh": "KUAS", + "c2ekur": "LABA", + "cdml7w": "LABS", + "c8ni2w": "LAJU", + "bgwfxm": "LAND", + "bkgybh": "LAPD", + "bkgyh7": "LCKM", + "bkgyk2": "LEAD", + "c1g5f2": "LFLO", + "bqsrz2": "LIFE", + "bkgymw": "LINK", + "bkgypr": "LION", + "ccbz52": "LIVE", + "caj9gh": "LMAX", + "bkgyyc": "LMPI", + "bkgz27": "LMSH", + "cb2a2w": "LOPI", + "bkgz52": "LPCK", + "bkgz7w": "LPGI", + "bkgzar": "LPIN", + "bn99ar": "LPKR", + "bn99dm": "LPLI", + "bkgzjc": "LPPF", + "bn99gh": "LPPS", + "bkgzp2": "LRNA", + "bn99jc": "LSIP", + "bkgzur": "LTLS", + "bny8z2": "LUCK", + "c1zlh7": "LUCY", + "caem3m": "MAHA", + "bkh177": "MAIN", + "cc463m": "MANG", + "bgwzm7": "MAPA", + "bkh1cw": "MAPB", + "bn98pr": "MAPI", + "bkh1im": "MARI", + "bkh1lh": "MARK", + "c2jw8m": "MASB", + "c9zd5r": "MAXI", + "bkh1r7": "MAYA", + "bkh1u2": "MBAP", + "c9fe52": "MBMA", + "bkh1ww": "MBSS", + "bkh1zr": "MBTO", + "bkh23m": "MCAS", + "c3apnm": "MCOL", + "bkh26h": "MCOR", + "bkh2c7": "MDIA", + "cff1lh": "MDIY", + "bkh2f2": "MDKA", + "bkh2hw": "MDKI", + "cgbphw": "MDLA", + "bkh2kr": "MDLN", + "bkh2nm": "MDRN", + "bn9a77": "MEDC", + "c71idm": "MEDS", + "bkh2tc": "MEGA", + "ccbqh7": "MEJA", + "c9fbu2": "MENN", + "che6cw": "MERI", + "bkh2w7": "MERK", + "bkh35r": "MFMI", + "c2cba2": "MGLV", + "bkh38m": "MGNA", + "bgxdcw": "MGRO", + "ccwk27": "MHKI", + "bn9afr": "MICE", + "bn9aim": "MIDI", + "bkh3h7": "MIKA", + "bkh427": "MINA", + "cg3epr": "MINE", + "bkh452": "MIRA", + "bkh47w": "MITI", + "ccbrar": "MKAP", + "bkh4dm": "MKPI", + "c7sz6h": "MKTR", + "bkh4gh": "MLBI", + "bkh4jc": "MLIA", + "bn9alh": "MLPL", + "bkh4p2": "MLPT", + "c87lz2": "MMIX", + "bkh4rw": "MMLP", + "bkh4ur": "MNCN", + "b9fksm": "MOLI", + "c6zya2": "MORA", + "ccbqmw": "MPIX", + "bkh3mw": "MPMX", + "bkh3pr": "MPOW", + "bn9azr": "MPPA", + "bkh3vh": "MPRO", + "c9ql7w": "MPXL", + "bkh3yc": "MRAT", + "bkhcur": "MREI", + "cakqgh": "MSIE", + "bgy7nm": "MSIN", + "cc466h": "MSJA", + "bkhcxm": "MSKY", + "cbe9qh": "MSTI", + "bkhd1h": "MTDL", + "c45acw": "MTEL", + "bgy82w": "MTFN", + "bkhd4c": "MTLA", + "c5w4lh": "MTMH", + "bpmq9c": "MTPS", + "bkhda2": "MTSM", + "bkhdfr": "MTWI", + "cajgyc": "MUTU", + "bkhcgh": "MYOH", + "bn9bkr": "MYOR", + "bkhcp2": "MYTX", + "cf6abh": "NAIK", + "c5es5r": "NANO", + "bkhc52": "NASA", + "c4earw": "NASI", + "bor3r7": "NATO", + "c8pvw7": "NAYZ", + "c9dmnm": "NCKL", + "bkhcar": "NELY", + "cecdh7": "NEST", + "c4zpnm": "NETV", + "bh2cm7": "NFCX", + "cc46f2": "NICE", + "bkhdoc": "NICK", + "c2og3m": "NICL", + "bkhdr7": "NIKL", + "c87ih7": "NINE", + "bkhdww": "NIRO", + "bkhdzr": "NISP", + "bkhe3m": "NOBU", + "c1mpp2": "NPGF", + "bkhe9c": "NRCA", + "c92ssm": "NSSS", + "c53nim": "NTBK", + "brkv8m": "NZIA", + "bkhdim": "OASA", + "cflf77": "OBAT", + "c4dnkr": "OBMD", + "c3a84c": "OILS", + "bkhdlh": "OKAS", + "c64i27": "OLIV", + "c7t2jc": "OMED", + "bkhekr": "OMRE", + "brkez2": "OPMS", + "c8sn2w": "PACK", + "c87qyc": "PADA", + "bkheqh": "PADI", + "bkhetc": "PALM", + "bqrcxm": "PAMG", + "bh3952": "PANI", + "bkhez2": "PANR", + "bkhf2w": "PANS", + "cdlta2": "PART", + "bkhf5r": "PBID", + "bkhf8m": "PBRX", + "bkhfbh": "PBSA", + "bkhfec": "PCAR", + "bkhfh7": "PDES", + "c7wzkr": "PDPP", + "bkhfk2": "PEGE", + "boeyr7": "PEHA", + "c8lqu2": "PEVE", + "bn9ch7": "PGAS", + "c8wmc7": "PGEO", + "btf6im": "PGJO", + "bkhfpr": "PGLI", + "bv2tpr": "PGUN", + "bkhfvh": "PICO", + "c9cy5r": "PIPA", + "bkhfyc": "PJAA", + "bkhh1h": "PKPK", + "bvsmnm": "PLAN", + "bkhh77": "PLIN", + "bt81mw": "PMJS", + "bx3rtc": "PMMP", + "che6fr": "PMUI", + "bn9d27": "PNBN", + "bn9d52": "PNBS", + "bvilbh": "PNGO", + "bkhhfr": "PNIN", + "bn9d7w": "PNLF", + "bkhhlh": "PNSE", + "bnw452": "POLA", + "bokeh7": "POLI", + "bqiq5r": "POLU", + "bkhg27": "POLY", + "bkhg7w": "PORT", + "bn9dar": "POWR", + "bv7ka2": "PPGL", + "bkhgdm": "PPRE", + "caj5xm": "PPRI", + "bkhggh": "PPRO", + "c7svf2": "PRAY", + "bkhgm7": "PRDA", + "bh3bnm": "PRIM", + "bkhgp2": "PSAB", + "bkhgrw": "PSDN", + "bssb7w": "PSGO", + "bkhgur": "PSKT", + "bkhgxm": "PSSI", + "bn9i4c": "PTBA", + "bwzbim": "PTDU", + "bkhif2": "PTIS", + "c8zh3m": "PTMP", + "cese7w": "PTMR", + "bkhihw": "PTPP", + "cb26pr": "PTPS", + "btowc7": "PTPW", + "bkhinm": "PTRO", + "bkhiqh": "PTSN", + "bkhitc": "PTSP", + "bkhiw7": "PUDP", + "btjqcw": "PURA", + "bvmvlh": "PURI", + "bkhiz2": "PWON", + "bkhj2w": "PYFA", + "bh3dar": "PZZA", + "c9pg7w": "RAAM", + "c6zy1h": "RAFI", + "bkhhu2": "RAJA", + "bn9iim": "RALS", + "bkhhzr": "RANC", + "cfl3oc": "RATU", + "bkhi3m": "RBMS", + "c6y4ur": "RCCC", + "bkhi6h": "RDTX", + "bsxdzr": "REAL", + "ca43u2": "RELF", + "bkhi9c": "RELI", + "cbe25r": "RGAS", + "bkhj5r": "RICY", + "bkhj8m": "RIGS", + "bh3par": "RISE", + "c4bf52": "RMKE", + "cafyoc": "RMKO", + "bvpnqh": "ROCK", + "bkhkur": "RODA", + "bua477": "RONY", + "bkhkxm": "ROTI", + "caoza2": "RSCH", + "c3atf2": "RSGK", + "bkhjh7": "RUIS", + "c3athw": "RUNS", + "bh4khw": "SAFE", + "c8ztkr": "SAGE", + "bkhl1h": "SAME", + "bu77h7": "SAMF", + "bkhl4c": "SAPX", + "bnmpyc": "SATU", + "c3apw7": "SBMA", + "bkhl77": "SCCO", + "bkhla2": "SCMA", + "bvpljc": "SCNP", + "bkhlcw": "SDMU", + "bkhlfr": "SDPC", + "bkhlim": "SDRA", + "c4s94c": "SEMA", + "bqigoc": "SFAN", + "bvdqa2": "SGER", + "bn9j9c": "SGRO", + "bkhlww": "SHID", + "bkhlzr": "SHIP", + "c5tatc": "SICO", + "bkhm6h": "SIDO", + "bn9jnm": "SILO", + "bkhmf2": "SIMP", + "bsn27w": "SINI", + "bkhpzr": "SIPD", + "bkhq3m": "SKBM", + "bkhq6h": "SKLT", + "bkhqc7": "SKRN", + "bs6kh7": "SLIS", + "bkhrmw": "SMAR", + "bn9lar": "SMBR", + "bn9ldm": "SMCB", + "bkhryc": "SMDM", + "bkhs27": "SMDR", + "cc93tc": "SMGA", + "bn9lgh": "SMGR", + "c9r7oc": "SMIL", + "bqrfh7": "SMKL", + "c5gqk2": "SMKM", + "cc45oc": "SMLE", + "bkhs7w": "SMMA", + "bkhsar": "SMMT", + "bn9ljc": "SMRA", + "bkhsjc": "SMSM", + "c1c3ur": "SNLK", + "bkhmkr": "SOCI", + "bv1prw": "SOFA", + "bvpia2": "SOHO", + "cd3j77": "SOLA", + "bkhmqh": "SONA", + "borblh": "SOSS", + "bo5fk2": "SOTS", + "c8hmu2": "SOUL", + "c8hmww": "SOUL", + "c8hmzr": "SOUL", + "bkhqhw": "SPMA", + "cdllww": "SPRE", + "bh4tpr": "SPTO", + "bkhqkr": "SQMI", + "bkhqnm": "SRAJ", + "bkhqw7": "SRSN", + "bkhqz2": "SRTG", + "bkhr2w": "SSIA", + "bkhr5r": "SSMS", + "bkhr8m": "SSTM", + "c5grjc": "STAA", + "bkhrbh": "STAR", + "cb1rcw": "STRK", + "bkhrec": "STTP", + "bn9l52": "SULI", + "c8irzr": "SUNI", + "bkhsp2": "SUPR", + "bkhsrw": "SURE", + "cbriz2": "SURI", + "bh4xyc": "SWAT", + "c6q9ww": "SWID", + "bkhsxm": "TALF", + "btnwec": "TAMA", + "bkht1h": "TAMU", + "c1pha2": "TAPG", + "bkht4c": "TARA", + "bkhta2": "TAXI", + "c4bfdm": "TAYS", + "bkhtcw": "TBIG", + "bn9m77": "TBLA", + "bkhtim": "TBMS", + "bkhtlh": "TCID", + "bh55nm": "TCPI", + "bsqufr": "TEBE", + "bkhtr7": "TELE", + "brgc52": "TFAS", + "bkhtzr": "TFCO", + "bkhu6h": "TGKA", + "bkhu9c": "TGRA", + "ca99f2": "TGUK", + "bkhuc7": "TIFA", + "bn9o2w": "TINS", + "bkhunm": "TIRA", + "bkhuqh": "TIRT", + "bn9o5r": "TKIM", + "c5tiqh": "TLDN", + "bn9o8m": "TLKM", + "bn9oec": "TMAS", + "bkhv8m": "TMPO", + "bh5cim": "TNCA", + "bkhvk2": "TOBA", + "c71edm": "TOOL", + "ccbqk2": "TOSK", + "bn9ok2": "TOTL", + "bkhvsm": "TOTO", + "bn9omw": "TOWR", + "bkhvyc": "TPIA", + "bkhw27": "TPMA", + "c6qatc": "TRGU", + "bki2lh": "TRIM", + "btfcbh": "TRIN", + "bki2oc": "TRIS", + "bviktc": "TRJA", + "c8zthw": "TRON", + "bki2u2": "TRST", + "c2cfa2": "TRUE", + "bh7r6h": "TRUK", + "bki2zr": "TRUS", + "bki33m": "TSPC", + "bh72cw": "TUGU", + "c9pfpr": "TYRE", + "bv1qlh": "UANG", + "bt7nnm": "UCID", + "cbazxm": "UDNG", + "bxjur7": "UFOE", + "bki36h": "ULTJ", + "bki39c": "UNIC", + "bzv6mw": "UNIQ", + "bn9pjc": "UNSP", + "ccanww": "UNTD", + "bn9pm7": "UNTR", + "bn9pp2": "UNVR", + "bo5fsm": "URBN", + "c2ruww": "UVCR", + "c8sn8m": "VAST", + "cesblh": "VERN", + "bx3pp2": "VICI", + "bki3nm": "VICO", + "bki3qh": "VINS", + "cchqzr": "VISI", + "bki45r": "VIVA", + "ca2e9c": "VKTR", + "bki4bh": "VOKS", + "bki4k2": "VRNA", + "c89txm": "VTNY", + "bki4mw": "WAPO", + "bki4pr": "WEGE", + "bki4sm": "WEHA", + "c4bevh": "WGSH", + "bki76h": "WICO", + "ca9dkr": "WIDI", + "bx86dm": "WIFI", + "bki79c": "WIIM", + "bn9q77": "WIKA", + "c8isf2": "WINE", + "c5yu77": "WINR", + "bn9qa2": "WINS", + "c5o9z2": "WIRG", + "bxjkec": "WMUU", + "bki7hw": "WOMF", + "bki7kr": "WOOD", + "bsnwf2": "WOWS", + "bki7z2": "WSBP", + "bn9r9c": "WTON", + "bnehec": "YELO", + "cfl49c": "YOII", + "bki8bh": "YPAS", + "bki7nm": "YULE", + "cgaarw": "YUPI", + "c7x3im": "ZATA", + "bhacdm": "ZBRA", + "bki7qh": "ZINC", + "bo5fyc": "ZONE", + "c1c1kr": "ZYRX", +} + +// GetIDXStock looks up an Indonesian stock by ticker symbol +func GetIDXStock(ticker string) (IDXStock, bool) { + stock, ok := IDXStocks[ticker] + return stock, ok +} + +// GetIDXStockID returns the MSN ID for a ticker, or empty string if not found +func GetIDXStockID(ticker string) string { + if stock, ok := IDXStocks[ticker]; ok { + return stock.ID + } + return "" +} + +// GetIDXTicker returns the ticker symbol for an MSN ID +func GetIDXTicker(id string) string { + return IDXIDToTicker[id] +} + +// GetAllIDXStocks returns all IDX stocks +func GetAllIDXStocks() map[string]IDXStock { + return IDXStocks +} + +// GetIDXStockCount returns the total number of IDX stocks +func GetIDXStockCount() int { + return len(IDXStocks) +} diff --git a/msn/msn_client.go b/msn/msn_client.go new file mode 100644 index 0000000..50dfb99 --- /dev/null +++ b/msn/msn_client.go @@ -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), "es); 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 = "es[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 +} diff --git a/msn/msn_screener.go b/msn/msn_screener.go new file mode 100644 index 0000000..f2fe532 --- /dev/null +++ b/msn/msn_screener.go @@ -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) + } +} diff --git a/msn/msn_stock.go b/msn/msn_stock.go new file mode 100644 index 0000000..0e0a1ef --- /dev/null +++ b/msn/msn_stock.go @@ -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: "es[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 +} diff --git a/msn/msn_types.go b/msn/msn_types.go new file mode 100644 index 0000000..dbf812a --- /dev/null +++ b/msn/msn_types.go @@ -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"` +} diff --git a/msn/news_analysis.go b/msn/news_analysis.go new file mode 100644 index 0000000..4635379 --- /dev/null +++ b/msn/news_analysis.go @@ -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 +} diff --git a/msn/rate_limiter.go b/msn/rate_limiter.go new file mode 100644 index 0000000..00e6e41 --- /dev/null +++ b/msn/rate_limiter.go @@ -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 +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..00dc6f3 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/scripts/e2e_run.sh b/scripts/e2e_run.sh new file mode 100755 index 0000000..9e93fdb --- /dev/null +++ b/scripts/e2e_run.sh @@ -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" diff --git a/scripts/export_dashboard.py b/scripts/export_dashboard.py new file mode 100644 index 0000000..9644c12 --- /dev/null +++ b/scripts/export_dashboard.py @@ -0,0 +1,1373 @@ +#!/usr/bin/env python3 +""" +Professional IDX Stock Dashboard - Excel Export + +Creates professional Excel dashboard with: +- Clean Excel Tables with AutoFilter on every column +- Proper number formatting (currency, percentages, ratios) +- Conditional formatting (color scales, data bars) +- Professional styling (consistent headers, colors) +- NO CHARTS - tables only for clean, data-focused look + +Usage: + uv run python scripts/export_dashboard.py --db output/stocks.db --output output/dashboard.xlsx +""" + +import argparse +import sqlite3 +import math +from datetime import datetime +from pathlib import Path +from typing import Optional, List, Dict, Any + +import xlsxwriter +from xlsxwriter.utility import xl_range + +# ============================================================================ +# Color Palette - Professional Finance Theme +# ============================================================================ + +COLORS = { + # Primary blues + 'primary_dark': '#1F4E79', + 'primary': '#2E75B6', + 'primary_light': '#5B9BD5', + 'primary_bg': '#D6DCE4', + + # Success greens + 'success_dark': '#375623', + 'success': '#70AD47', + 'success_light': '#C6EFCE', + 'success_text': '#006100', + + # Danger reds + 'danger_dark': '#833C0C', + 'danger': '#C00000', + 'danger_light': '#FFC7CE', + 'danger_text': '#9C0006', + + # Warning yellows + 'warning_dark': '#7F6000', + 'warning': '#FFC000', + 'warning_light': '#FFEB9C', + 'warning_text': '#9C5700', + + # Neutral + 'white': '#FFFFFF', + 'light_gray': '#F2F2F2', + 'mid_gray': '#D9D9D9', + 'dark_gray': '#404040', + 'black': '#000000', +} + +# Table styles (built-in Excel styles) +TABLE_STYLE_BLUE = 'Table Style Medium 2' +TABLE_STYLE_GREEN = 'Table Style Medium 7' +TABLE_STYLE_RED = 'Table Style Medium 3' +TABLE_STYLE_ORANGE = 'Table Style Medium 4' +TABLE_STYLE_DARK = 'Table Style Dark 1' + + +# ============================================================================ +# Utility Functions +# ============================================================================ + +def safe_float(value, default=None) -> Optional[float]: + """Safely convert value to float.""" + if value is None: + return default + try: + return float(value) + except (ValueError, TypeError): + return default + + +def calc_graham_number(eps: float, bvps: float) -> Optional[float]: + """Calculate Graham Number = sqrt(22.5 * EPS * BVPS).""" + if eps is None or bvps is None or eps <= 0 or bvps <= 0: + return None + return math.sqrt(22.5 * eps * bvps) + + +def calc_graham_margin(price: float, graham: float) -> Optional[float]: + """Calculate margin of safety vs Graham Number as percentage.""" + if price is None or graham is None or price == 0: + return None + return ((graham - price) / price) * 100 + + +def calc_52w_position(price: float, high: float, low: float) -> Optional[float]: + """Calculate position within 52-week range (0-100).""" + if price is None or high is None or low is None: + return None + if high == low: + return 50.0 + return ((price - low) / (high - low)) * 100 + + +def calc_health_score(data: dict) -> int: + """Calculate financial health score (0-8 points).""" + score = 0 + if safe_float(data.get('roe'), 0) > 10: + score += 1 + if safe_float(data.get('roa'), 0) > 5: + score += 1 + if safe_float(data.get('net_margin'), 0) > 0: + score += 1 + if safe_float(data.get('operating_margin'), 0) > 0: + score += 1 + de = safe_float(data.get('debt_to_equity')) + if de is not None and de < 1: + score += 1 + cr = safe_float(data.get('current_ratio')) + if cr is not None and cr > 1: + score += 1 + if safe_float(data.get('revenue_growth'), 0) > 0: + score += 1 + if safe_float(data.get('earnings_growth'), 0) > 0: + score += 1 + return score + + +def calc_dividend_safety(payout: float, de_ratio: float) -> str: + """Rate dividend safety based on payout ratio and leverage.""" + if payout is None: + return "N/A" + score = 0 + if payout < 50: + score += 3 + elif payout < 70: + score += 2 + elif payout < 90: + score += 1 + if de_ratio is not None: + if de_ratio < 0.5: + score += 2 + elif de_ratio < 1.0: + score += 1 + if score >= 4: + return "SAFE" + elif score >= 2: + return "OK" + return "RISKY" + + +# ============================================================================ +# Dashboard Generator +# ============================================================================ + +class ProfessionalDashboard: + """Professional Excel Dashboard Generator using xlsxwriter.""" + + def __init__(self, workbook: xlsxwriter.Workbook, conn: sqlite3.Connection, date: str): + self.wb = workbook + self.conn = conn + self.date = date + self._setup_formats() + self._load_data() + + def _setup_formats(self): + """Setup all formatting styles for consistent look.""" + + # ===== TITLE & HEADER FORMATS ===== + 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({ + 'font_size': 10, + 'font_color': COLORS['dark_gray'], + 'italic': True, + }) + + # Section headers with solid background + 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', + 'border': 1, + 'border_color': COLORS['primary_dark'], + }) + + self.fmt_section_green = self.wb.add_format({ + 'bold': True, + 'font_size': 14, + 'font_color': COLORS['white'], + 'bg_color': COLORS['success_dark'], + 'align': 'center', + 'valign': 'vcenter', + 'border': 1, + }) + + self.fmt_section_red = self.wb.add_format({ + 'bold': True, + 'font_size': 14, + 'font_color': COLORS['white'], + 'bg_color': COLORS['danger_dark'], + 'align': 'center', + 'valign': 'vcenter', + 'border': 1, + }) + + self.fmt_section_orange = self.wb.add_format({ + 'bold': True, + 'font_size': 14, + 'font_color': COLORS['white'], + 'bg_color': COLORS['warning_dark'], + 'align': 'center', + 'valign': 'vcenter', + 'border': 1, + }) + + # ===== NUMBER FORMATS ===== + # Integer (no decimals) + self.fmt_int = self.wb.add_format({ + 'num_format': '#,##0', + 'align': 'right', + }) + + # Two decimal places + self.fmt_dec2 = self.wb.add_format({ + 'num_format': '#,##0.00', + 'align': 'right', + }) + + # One decimal place + self.fmt_dec1 = self.wb.add_format({ + 'num_format': '#,##0.0', + 'align': 'right', + }) + + # Percentage with sign (for change values stored as decimal like 0.05 = 5%) + self.fmt_pct_sign = self.wb.add_format({ + 'num_format': '+0.00%;-0.00%;0.00%', + 'align': 'right', + }) + + # Percentage from whole number (for values stored as 5.0 = 5%) + self.fmt_pct = self.wb.add_format({ + 'num_format': '0.00"%"', + 'align': 'right', + }) + + self.fmt_pct1 = self.wb.add_format({ + 'num_format': '0.0"%"', + 'align': 'right', + }) + + # Ratio (2 decimal places, no suffix) + self.fmt_ratio = self.wb.add_format({ + 'num_format': '0.00', + 'align': 'right', + }) + + # Currency Rupiah + self.fmt_currency = self.wb.add_format({ + 'num_format': '"Rp "#,##0', + 'align': 'right', + }) + + # Billions + self.fmt_billions = self.wb.add_format({ + 'num_format': '#,##0.00"B"', + 'align': 'right', + }) + + # Trillions + self.fmt_trillions = self.wb.add_format({ + 'num_format': '#,##0.00"T"', + 'align': 'right', + }) + + # Score (centered integer) + self.fmt_score = self.wb.add_format({ + 'num_format': '0', + 'align': 'center', + 'bold': True, + }) + + # ===== STATUS FORMATS ===== + self.fmt_good = self.wb.add_format({ + 'bg_color': COLORS['success_light'], + 'font_color': COLORS['success_text'], + 'bold': True, + 'align': 'center', + 'border': 1, + 'border_color': COLORS['success'], + }) + + self.fmt_bad = self.wb.add_format({ + 'bg_color': COLORS['danger_light'], + 'font_color': COLORS['danger_text'], + 'bold': True, + 'align': 'center', + 'border': 1, + 'border_color': COLORS['danger'], + }) + + self.fmt_warn = self.wb.add_format({ + 'bg_color': COLORS['warning_light'], + 'font_color': COLORS['warning_text'], + 'bold': True, + 'align': 'center', + 'border': 1, + 'border_color': COLORS['warning'], + }) + + self.fmt_neutral = self.wb.add_format({ + 'bg_color': COLORS['light_gray'], + 'align': 'center', + }) + + # ===== KPI BOX FORMATS ===== + 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, + }) + + def _load_data(self): + """Load and enrich stock data from database.""" + query = """ + SELECT + s.id, s.ticker, s.name, s.sector, s.industry, + p.price, p.price_change, p.price_change_pct, + p.price_52w_high, p.price_52w_low, + p.volume, p.avg_volume, p.market_cap, + p.return_1w, p.return_1m, p.return_3m, p.return_6m, p.return_ytd, p.return_1y, + r.pe_ratio, r.pb_ratio, r.ps_ratio, r.ev_ebitda, + r.dividend_yield, r.payout_ratio, r.roe, r.roa, r.roic, + r.gross_margin, r.operating_margin, r.net_margin, + r.debt_to_equity, r.current_ratio, r.quick_ratio, + r.revenue_growth, r.earnings_growth, r.eps, r.bvps, + sh.bullish_pct, sh.bearish_pct, sh.neutral_pct + FROM stocks s + LEFT JOIN price_history p ON s.id = p.stock_id AND p.scrape_date = ? + LEFT JOIN ( + SELECT stock_id, pe_ratio, pb_ratio, ps_ratio, ev_ebitda, + dividend_yield, payout_ratio, roe, roa, roic, + gross_margin, operating_margin, net_margin, + debt_to_equity, current_ratio, quick_ratio, + revenue_growth, earnings_growth, eps, bvps + FROM ratios_history WHERE scrape_date = ? + GROUP BY stock_id HAVING MAX(year) + ) r ON s.id = r.stock_id + LEFT JOIN ( + SELECT stock_id, bullish_pct, bearish_pct, neutral_pct + FROM sentiment_history WHERE scrape_date = ? AND time_range_enum = 'week' + ) sh ON s.id = sh.stock_id + WHERE p.price IS NOT NULL + ORDER BY p.market_cap DESC NULLS LAST + """ + rows = self.conn.execute(query, (self.date, self.date, self.date)).fetchall() + self.stocks = [dict(row) for row in rows] + + # Enrich with calculated fields + for s in self.stocks: + # Market cap conversions + mcap = safe_float(s.get('market_cap'), 0) + s['market_cap_b'] = mcap / 1e9 + s['market_cap_t'] = mcap / 1e12 + + # Graham number & margin + s['graham_num'] = calc_graham_number( + safe_float(s.get('eps')), safe_float(s.get('bvps'))) + s['graham_margin'] = calc_graham_margin( + safe_float(s.get('price')), s['graham_num']) + + # 52-week position + s['pos_52w'] = calc_52w_position( + safe_float(s.get('price')), + safe_float(s.get('price_52w_high')), + safe_float(s.get('price_52w_low'))) + + # Health score + s['health_score'] = calc_health_score(s) + + # Convert price_change_pct to decimal for proper % formatting + pct = safe_float(s.get('price_change_pct')) + s['price_change_pct_dec'] = pct / 100 if pct is not None else None + + def _add_table(self, ws, start_row: int, start_col: int, data: list, + columns: list, table_name: str, style: str = TABLE_STYLE_BLUE, + total_row: bool = False) -> int: + """ + Add Excel Table with proper formatting and AutoFilter. + Returns the row number after the table. + """ + if not data: + ws.write(start_row, start_col, "No data available", self.fmt_neutral) + return start_row + 1 + + end_row = start_row + len(data) + end_col = start_col + len(columns) - 1 + + # Build table column configuration + 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 cells with formatting + 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 + + # Apply transform if specified + transform = col_def.get('transform') + if transform and value is not None: + value = transform(value) + + cell_fmt = col_def.get('format') + ws.write(start_row + 1 + row_idx, start_col + col_idx, value, cell_fmt) + + # Create the table + table_range = xl_range(start_row, start_col, end_row, end_col) + ws.add_table(table_range, { + 'name': table_name, + 'style': 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 _add_cond_fmt(self, ws, start_row: int, end_row: int, col: int, + fmt_type: str, **kwargs): + """Add conditional formatting to a column range.""" + cell_range = xl_range(start_row, col, end_row, col) + + if fmt_type == '3_color_scale': + ws.conditional_format(cell_range, { + 'type': '3_color_scale', + 'min_color': kwargs.get('min_color', '#F8696B'), + 'mid_color': kwargs.get('mid_color', '#FFEB84'), + 'max_color': kwargs.get('max_color', '#63BE7B'), + }) + elif fmt_type == '2_color_scale': + ws.conditional_format(cell_range, { + 'type': '2_color_scale', + 'min_color': kwargs.get('min_color', '#FFFFFF'), + 'max_color': kwargs.get('max_color', '#63BE7B'), + }) + elif fmt_type == 'data_bar': + ws.conditional_format(cell_range, { + 'type': 'data_bar', + 'bar_color': kwargs.get('bar_color', COLORS['primary_light']), + 'bar_solid': True, + }) + elif fmt_type == 'icon_set': + ws.conditional_format(cell_range, { + 'type': 'icon_set', + 'icon_style': kwargs.get('icon_style', '3_arrows'), + }) + elif fmt_type == 'pos_neg': + # Green for positive, red for negative + ws.conditional_format(cell_range, { + 'type': 'cell', + 'criteria': '>', + 'value': 0, + 'format': self.wb.add_format({ + 'bg_color': COLORS['success_light'], + 'font_color': COLORS['success_text'], + }), + }) + ws.conditional_format(cell_range, { + 'type': 'cell', + 'criteria': '<', + 'value': 0, + 'format': self.wb.add_format({ + 'bg_color': COLORS['danger_light'], + 'font_color': COLORS['danger_text'], + }), + }) + + # ========================================================================= + # SHEET: Executive Summary + # ========================================================================= + def create_summary(self): + """Create Executive Summary sheet with KPIs and overview tables.""" + ws = self.wb.add_worksheet("Executive Summary") + ws.set_zoom(90) + + # Title + ws.merge_range('A1:L1', f"IDX Market Dashboard - {self.date}", self.fmt_title) + ws.set_row(0, 30) + ws.write('A2', f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')} | Data Date: {self.date}", self.fmt_subtitle) + + # Calculate KPIs + total = len(self.stocks) + gainers = sum(1 for s in self.stocks if safe_float(s.get('price_change_pct'), 0) > 0) + losers = sum(1 for s in self.stocks if safe_float(s.get('price_change_pct'), 0) < 0) + total_mcap = sum(safe_float(s.get('market_cap'), 0) for s in self.stocks) + + pe_vals = [s['pe_ratio'] for s in self.stocks if s.get('pe_ratio') and 0 < s['pe_ratio'] < 100] + avg_pe = sum(pe_vals) / len(pe_vals) if pe_vals else 0 + + div_vals = [s['dividend_yield'] for s in self.stocks if s.get('dividend_yield') and s['dividend_yield'] > 0] + avg_div = sum(div_vals) / len(div_vals) if div_vals else 0 + + # KPI row + kpis = [ + ('Total Stocks', str(total)), + ('Market Cap', f"{total_mcap/1e12:.1f}T"), + ('Gainers', str(gainers)), + ('Losers', str(losers)), + ('Avg P/E', f"{avg_pe:.1f}"), + ('Avg Yield', f"{avg_div:.2f}%"), + ] + + row = 4 + for col_idx, (label, value) in enumerate(kpis): + ws.write(row, col_idx * 2, label, self.fmt_kpi_label) + ws.write(row + 1, col_idx * 2, value, self.fmt_kpi_value) + ws.set_column(col_idx * 2, col_idx * 2, 12) + + # Market Overview Table + row = 8 + ws.merge_range(row, 0, row, 4, "MARKET OVERVIEW", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + overview_data = [ + {'metric': 'Total Stocks', 'value': total}, + {'metric': 'Total Market Cap (T)', 'value': total_mcap / 1e12}, + {'metric': 'Gainers', 'value': gainers}, + {'metric': 'Losers', 'value': losers}, + {'metric': 'Unchanged', 'value': total - gainers - losers}, + {'metric': 'Avg P/E Ratio', 'value': avg_pe}, + {'metric': 'Avg Div Yield %', 'value': avg_div}, + ] + + overview_cols = [ + {'header': 'Metric', 'key': 'metric', 'width': 20}, + {'header': 'Value', 'key': 'value', 'width': 15, 'format': self.fmt_dec2}, + ] + + row = self._add_table(ws, row, 0, overview_data, overview_cols, 'MarketOverview') + + # Top Gainers + row += 1 + ws.merge_range(row, 0, row, 5, "TOP 15 GAINERS", self.fmt_section_green) + ws.set_row(row, 22) + row += 1 + + top_gainers = sorted( + [s for s in self.stocks if s.get('price_change_pct')], + key=lambda x: safe_float(x['price_change_pct'], 0), reverse=True + )[:15] + + gainer_cols = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 22}, + {'header': 'Sector', 'key': 'sector', 'width': 16}, + {'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_int}, + {'header': 'Change', 'key': 'price_change', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'Chg%', 'key': 'price_change_pct_dec', 'width': 10, 'format': self.fmt_pct_sign}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, top_gainers, gainer_cols, 'TopGainers', TABLE_STYLE_GREEN) + self._add_cond_fmt(ws, data_start, end_row - 1, 5, 'data_bar', bar_color=COLORS['success']) + + # Top Losers + row = end_row + 1 + ws.merge_range(row, 0, row, 5, "TOP 15 LOSERS", self.fmt_section_red) + ws.set_row(row, 22) + row += 1 + + top_losers = sorted( + [s for s in self.stocks if s.get('price_change_pct')], + key=lambda x: safe_float(x['price_change_pct'], 0) + )[:15] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, top_losers, gainer_cols, 'TopLosers', TABLE_STYLE_RED) + self._add_cond_fmt(ws, data_start, end_row - 1, 5, 'data_bar', bar_color=COLORS['danger']) + + # Sector Breakdown (right side) + sector_row = 8 + ws.merge_range(sector_row, 7, sector_row, 11, "SECTOR BREAKDOWN", self.fmt_section) + ws.set_row(sector_row, 22) + sector_row += 1 + + sector_agg = {} + for s in self.stocks: + sector = s.get('sector') or 'Unknown' + if sector not in sector_agg: + sector_agg[sector] = {'count': 0, 'mcap': 0, 'gainers': 0, 'losers': 0} + sector_agg[sector]['count'] += 1 + sector_agg[sector]['mcap'] += safe_float(s.get('market_cap'), 0) + if safe_float(s.get('price_change_pct'), 0) > 0: + sector_agg[sector]['gainers'] += 1 + elif safe_float(s.get('price_change_pct'), 0) < 0: + sector_agg[sector]['losers'] += 1 + + sector_data = [ + {'sector': k, 'count': v['count'], 'mcap': v['mcap'] / 1e12, + 'gainers': v['gainers'], 'losers': v['losers']} + for k, v in sorted(sector_agg.items(), key=lambda x: x[1]['mcap'], reverse=True) + ] + + sector_cols = [ + {'header': 'Sector', 'key': 'sector', 'width': 18}, + {'header': 'Stocks', 'key': 'count', 'width': 8, 'format': self.fmt_int, 'total_function': 'sum'}, + {'header': 'MCap(T)', 'key': 'mcap', 'width': 10, 'format': self.fmt_dec2, 'total_function': 'sum'}, + {'header': 'Gainers', 'key': 'gainers', 'width': 9, 'format': self.fmt_int, 'total_function': 'sum'}, + {'header': 'Losers', 'key': 'losers', 'width': 9, 'format': self.fmt_int, 'total_function': 'sum'}, + ] + + self._add_table(ws, sector_row, 7, sector_data, sector_cols, 'SectorBreakdown', total_row=True) + + ws.freeze_panes(3, 0) + + # ========================================================================= + # SHEET: All Stocks + # ========================================================================= + def create_all_stocks(self): + """Create comprehensive All Stocks data sheet.""" + ws = self.wb.add_worksheet("All Stocks") + ws.set_zoom(85) + + columns = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 22}, + {'header': 'Sector', 'key': 'sector', 'width': 16}, + {'header': 'Industry', 'key': 'industry', 'width': 18}, + {'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_int}, + {'header': 'Chg', 'key': 'price_change', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'Chg%', 'key': 'price_change_pct_dec', 'width': 9, 'format': self.fmt_pct_sign}, + {'header': 'MCap(B)', 'key': 'market_cap_b', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'Volume', 'key': 'volume', 'width': 12, 'format': self.fmt_int}, + {'header': 'P/E', 'key': 'pe_ratio', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'P/B', 'key': 'pb_ratio', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'P/S', 'key': 'ps_ratio', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'Div%', 'key': 'dividend_yield', 'width': 7, 'format': self.fmt_pct}, + {'header': 'ROE%', 'key': 'roe', 'width': 8, 'format': self.fmt_pct}, + {'header': 'ROA%', 'key': 'roa', 'width': 8, 'format': self.fmt_pct}, + {'header': 'Net%', 'key': 'net_margin', 'width': 8, 'format': self.fmt_pct}, + {'header': 'D/E', 'key': 'debt_to_equity', 'width': 7, 'format': self.fmt_dec2}, + {'header': 'Current', 'key': 'current_ratio', 'width': 8, 'format': self.fmt_dec2}, + {'header': '52H', 'key': 'price_52w_high', 'width': 10, 'format': self.fmt_int}, + {'header': '52L', 'key': 'price_52w_low', 'width': 10, 'format': self.fmt_int}, + {'header': '52W%', 'key': 'pos_52w', 'width': 8, 'format': self.fmt_pct1}, + {'header': '1W%', 'key': 'return_1w', 'width': 8, 'format': self.fmt_pct}, + {'header': '1M%', 'key': 'return_1m', 'width': 8, 'format': self.fmt_pct}, + {'header': 'YTD%', 'key': 'return_ytd', 'width': 8, 'format': self.fmt_pct}, + {'header': '1Y%', 'key': 'return_1y', 'width': 8, 'format': self.fmt_pct}, + {'header': 'Graham', 'key': 'graham_num', 'width': 10, 'format': self.fmt_int}, + {'header': 'GrhMgn%', 'key': 'graham_margin', 'width': 10, 'format': self.fmt_pct1}, + {'header': 'Score', 'key': 'health_score', 'width': 7, 'format': self.fmt_score}, + ] + + end_row = self._add_table(ws, 0, 0, self.stocks, columns, 'AllStocksData') + + # Conditional formatting + n = len(self.stocks) + self._add_cond_fmt(ws, 2, n + 1, 6, '3_color_scale') # Chg% + self._add_cond_fmt(ws, 2, n + 1, 13, 'data_bar', bar_color=COLORS['success']) # ROE + self._add_cond_fmt(ws, 2, n + 1, 16, '3_color_scale', + min_color='#63BE7B', mid_color='#FFEB84', max_color='#F8696B') # D/E reversed + self._add_cond_fmt(ws, 2, n + 1, 20, 'data_bar', bar_color=COLORS['primary_light']) # 52W% + self._add_cond_fmt(ws, 2, n + 1, 21, 'pos_neg') # 1W% + self._add_cond_fmt(ws, 2, n + 1, 22, 'pos_neg') # 1M% + self._add_cond_fmt(ws, 2, n + 1, 23, 'pos_neg') # YTD% + self._add_cond_fmt(ws, 2, n + 1, 24, 'pos_neg') # 1Y% + self._add_cond_fmt(ws, 2, n + 1, 26, '3_color_scale') # Graham Margin + self._add_cond_fmt(ws, 2, n + 1, 27, 'icon_set', icon_style='3_arrows') # Score + + ws.freeze_panes(1, 2) + + # ========================================================================= + # SHEET: Valuation + # ========================================================================= + def create_valuation(self): + """Create Valuation Analysis sheet.""" + ws = self.wb.add_worksheet("Valuation") + + ws.merge_range('A1:J1', "Valuation Analysis", self.fmt_title) + ws.set_row(0, 26) + + # Value Opportunities + row = 3 + ws.merge_range(row, 0, row, 9, "Value Opportunities (Low P/E + Positive EPS)", self.fmt_section_green) + ws.set_row(row, 22) + row += 1 + + value_stocks = [] + for s in self.stocks: + pe = safe_float(s.get('pe_ratio')) + eps = safe_float(s.get('eps')) + if pe and 0 < pe < 15 and eps and eps > 0: + value_stocks.append({ + 'ticker': s['ticker'], + 'name': s['name'], + 'sector': s.get('sector') or '-', + 'price': safe_float(s['price']), + 'pe': pe, + 'pb': safe_float(s.get('pb_ratio')), + 'eps': eps, + 'bvps': safe_float(s.get('bvps')), + 'graham': s['graham_num'], + 'margin': s['graham_margin'], + }) + + value_stocks.sort(key=lambda x: x.get('margin') or -999, reverse=True) + + val_cols = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 20}, + {'header': 'Sector', 'key': 'sector', 'width': 14}, + {'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_int}, + {'header': 'P/E', 'key': 'pe', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'P/B', 'key': 'pb', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'EPS', 'key': 'eps', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'BVPS', 'key': 'bvps', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'Graham', 'key': 'graham', 'width': 10, 'format': self.fmt_int}, + {'header': 'Margin%', 'key': 'margin', 'width': 10, 'format': self.fmt_pct1}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, value_stocks[:30], val_cols, 'ValueStocks', TABLE_STYLE_GREEN) + self._add_cond_fmt(ws, data_start, end_row - 1, 9, '3_color_scale', + min_color='#F8696B', mid_color='#FFFFFF', max_color='#63BE7B') + + # Sector Valuation + row = end_row + 2 + ws.merge_range(row, 0, row, 3, "Sector Valuation Comparison", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + sector_pe = {} + for s in self.stocks: + sector = s.get('sector') or 'Unknown' + if sector not in sector_pe: + sector_pe[sector] = {'pe': [], 'pb': [], 'count': 0} + sector_pe[sector]['count'] += 1 + pe = safe_float(s.get('pe_ratio')) + pb = safe_float(s.get('pb_ratio')) + if pe and 0 < pe < 100: + sector_pe[sector]['pe'].append(pe) + if pb and pb > 0: + sector_pe[sector]['pb'].append(pb) + + def avg(lst): + return sum(lst) / len(lst) if lst else None + + sector_data = [ + { + 'sector': k, + 'count': v['count'], + 'avg_pe': avg(v['pe']), + 'avg_pb': avg(v['pb']), + } + for k, v in sorted(sector_pe.items(), key=lambda x: x[1]['count'], reverse=True) + ] + + sector_cols = [ + {'header': 'Sector', 'key': 'sector', 'width': 20}, + {'header': 'Stocks', 'key': 'count', 'width': 10, 'format': self.fmt_int}, + {'header': 'Avg P/E', 'key': 'avg_pe', 'width': 12, 'format': self.fmt_dec2}, + {'header': 'Avg P/B', 'key': 'avg_pb', 'width': 12, 'format': self.fmt_dec2}, + ] + + self._add_table(ws, row, 0, sector_data, sector_cols, 'SectorValuation') + + # ========================================================================= + # SHEET: Dividends + # ========================================================================= + def create_dividends(self): + """Create Dividend Analysis sheet.""" + ws = self.wb.add_worksheet("Dividends") + + ws.merge_range('A1:L1', "Dividend Analysis", self.fmt_title) + ws.set_row(0, 26) + + # Top Dividend Yield + row = 3 + ws.merge_range(row, 0, row, 8, "Top Dividend Yield Stocks", self.fmt_section_green) + ws.set_row(row, 22) + row += 1 + + div_stocks = [] + for s in self.stocks: + div = safe_float(s.get('dividend_yield')) + if div and div > 0: + payout = safe_float(s.get('payout_ratio')) + de = safe_float(s.get('debt_to_equity')) + safety = calc_dividend_safety(payout, de) + div_stocks.append({ + 'ticker': s['ticker'], + 'name': s['name'], + 'sector': s.get('sector') or '-', + 'price': safe_float(s['price']), + 'div': div, + 'payout': payout, + 'pe': safe_float(s.get('pe_ratio')), + 'de': de, + 'safety': safety, + }) + + div_stocks.sort(key=lambda x: x['div'], reverse=True) + + div_cols = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 20}, + {'header': 'Sector', 'key': 'sector', 'width': 14}, + {'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_int}, + {'header': 'Yield%', 'key': 'div', 'width': 10, 'format': self.fmt_pct}, + {'header': 'Payout%', 'key': 'payout', 'width': 10, 'format': self.fmt_pct}, + {'header': 'P/E', 'key': 'pe', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'D/E', 'key': 'de', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'Safety', 'key': 'safety', 'width': 10}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, div_stocks, div_cols, 'DividendStocks', TABLE_STYLE_GREEN) + self._add_cond_fmt(ws, data_start, end_row - 1, 4, 'data_bar', bar_color=COLORS['success']) + + # Apply safety cell formatting + for i, stock in enumerate(div_stocks): + cell_row = row + 1 + i + safety = stock['safety'] + if safety == 'SAFE': + fmt = self.fmt_good + elif safety == 'OK': + fmt = self.fmt_warn + else: + fmt = self.fmt_bad + ws.write(cell_row, 8, safety, fmt) + + # ========================================================================= + # SHEET: Financial Health + # ========================================================================= + def create_financial_health(self): + """Create Financial Health Analysis sheet.""" + ws = self.wb.add_worksheet("Financial Health") + + ws.merge_range('A1:L1', "Financial Health Analysis", self.fmt_title) + ws.set_row(0, 26) + + # Most Profitable (High ROE) + row = 3 + ws.merge_range(row, 0, row, 11, "Most Profitable Companies (by ROE)", self.fmt_section_green) + ws.set_row(row, 22) + row += 1 + + profitable = [s for s in self.stocks if safe_float(s.get('roe'), 0) > 15] + profitable.sort(key=lambda x: safe_float(x.get('roe'), 0), reverse=True) + + health_cols = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 18}, + {'header': 'Sector', 'key': 'sector', 'width': 12}, + {'header': 'ROE%', 'key': 'roe', 'width': 9, 'format': self.fmt_pct}, + {'header': 'ROA%', 'key': 'roa', 'width': 9, 'format': self.fmt_pct}, + {'header': 'ROIC%', 'key': 'roic', 'width': 9, 'format': self.fmt_pct}, + {'header': 'Gross%', 'key': 'gross_margin', 'width': 9, 'format': self.fmt_pct}, + {'header': 'Op%', 'key': 'operating_margin', 'width': 8, 'format': self.fmt_pct}, + {'header': 'Net%', 'key': 'net_margin', 'width': 8, 'format': self.fmt_pct}, + {'header': 'D/E', 'key': 'debt_to_equity', 'width': 7, 'format': self.fmt_dec2}, + {'header': 'Current', 'key': 'current_ratio', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'Score', 'key': 'health_score', 'width': 7, 'format': self.fmt_score}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, profitable[:35], health_cols, 'ProfitableStocks', TABLE_STYLE_GREEN) + self._add_cond_fmt(ws, data_start, end_row - 1, 3, 'data_bar', bar_color=COLORS['success']) + self._add_cond_fmt(ws, data_start, end_row - 1, 9, '3_color_scale', + min_color='#63BE7B', mid_color='#FFEB84', max_color='#F8696B') + self._add_cond_fmt(ws, data_start, end_row - 1, 11, 'icon_set', icon_style='3_traffic_lights') + + # All stocks financial metrics + row = end_row + 2 + ws.merge_range(row, 0, row, 11, "All Stocks Financial Metrics", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + self._add_table(ws, row, 0, self.stocks, health_cols, 'AllFinancialMetrics') + + # ========================================================================= + # SHEET: Momentum + # ========================================================================= + def create_momentum(self): + """Create Momentum & Technical Analysis sheet.""" + ws = self.wb.add_worksheet("Momentum") + + ws.merge_range('A1:J1', "Momentum & Technical Analysis", self.fmt_title) + ws.set_row(0, 26) + + # Price Momentum Matrix + row = 3 + ws.merge_range(row, 0, row, 9, "Price Momentum Matrix", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + mom_cols = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 18}, + {'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_int}, + {'header': '52W Pos%', 'key': 'pos_52w', 'width': 10, 'format': self.fmt_pct1}, + {'header': '1W%', 'key': 'return_1w', 'width': 8, 'format': self.fmt_pct}, + {'header': '1M%', 'key': 'return_1m', 'width': 8, 'format': self.fmt_pct}, + {'header': '3M%', 'key': 'return_3m', 'width': 8, 'format': self.fmt_pct}, + {'header': '6M%', 'key': 'return_6m', 'width': 8, 'format': self.fmt_pct}, + {'header': 'YTD%', 'key': 'return_ytd', 'width': 8, 'format': self.fmt_pct}, + {'header': '1Y%', 'key': 'return_1y', 'width': 8, 'format': self.fmt_pct}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, self.stocks, mom_cols, 'MomentumMatrix') + + n = len(self.stocks) + self._add_cond_fmt(ws, data_start, end_row - 1, 3, 'data_bar', bar_color=COLORS['primary_light']) + for col in [4, 5, 6, 7, 8, 9]: + self._add_cond_fmt(ws, data_start, end_row - 1, col, 'pos_neg') + + # Near 52W High + row = end_row + 2 + ws.merge_range(row, 0, row, 7, "Near 52-Week High (>80%)", self.fmt_section_green) + ws.set_row(row, 22) + row += 1 + + near_high = [s for s in self.stocks if s['pos_52w'] and s['pos_52w'] > 80] + near_high.sort(key=lambda x: x['pos_52w'], reverse=True) + + high_cols = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 18}, + {'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_int}, + {'header': '52H', 'key': 'price_52w_high', 'width': 10, 'format': self.fmt_int}, + {'header': '52W%', 'key': 'pos_52w', 'width': 9, 'format': self.fmt_pct1}, + {'header': '1M%', 'key': 'return_1m', 'width': 8, 'format': self.fmt_pct}, + {'header': 'YTD%', 'key': 'return_ytd', 'width': 8, 'format': self.fmt_pct}, + {'header': '1Y%', 'key': 'return_1y', 'width': 8, 'format': self.fmt_pct}, + ] + + end_row = self._add_table(ws, row, 0, near_high[:25], high_cols, 'Near52High', TABLE_STYLE_GREEN) + + # Near 52W Low + row = end_row + 2 + ws.merge_range(row, 0, row, 7, "Near 52-Week Low (<20%)", self.fmt_section_red) + ws.set_row(row, 22) + row += 1 + + near_low = [s for s in self.stocks if s['pos_52w'] and s['pos_52w'] < 20] + near_low.sort(key=lambda x: x['pos_52w']) + + self._add_table(ws, row, 0, near_low[:25], high_cols, 'Near52Low', TABLE_STYLE_RED) + + ws.freeze_panes(4, 2) + + # ========================================================================= + # SHEET: Sectors + # ========================================================================= + def create_sectors(self): + """Create Sector Comparison sheet.""" + ws = self.wb.add_worksheet("Sectors") + + ws.merge_range('A1:J1', "Sector Comparison", self.fmt_title) + ws.set_row(0, 26) + + # Aggregate sector data + sector_agg = {} + for s in self.stocks: + sector = s.get('sector') or 'Unknown' + if sector not in sector_agg: + sector_agg[sector] = { + 'count': 0, 'mcap': 0, + 'pe': [], 'pb': [], 'roe': [], 'div': [], 'ytd': [], 'de': [] + } + sector_agg[sector]['count'] += 1 + sector_agg[sector]['mcap'] += safe_float(s.get('market_cap'), 0) + + pe = safe_float(s.get('pe_ratio')) + if pe and 0 < pe < 100: + sector_agg[sector]['pe'].append(pe) + pb = safe_float(s.get('pb_ratio')) + if pb and pb > 0: + sector_agg[sector]['pb'].append(pb) + roe = safe_float(s.get('roe')) + if roe: + sector_agg[sector]['roe'].append(roe) + div = safe_float(s.get('dividend_yield')) + if div and div > 0: + sector_agg[sector]['div'].append(div) + ytd = safe_float(s.get('return_ytd')) + if ytd is not None: + sector_agg[sector]['ytd'].append(ytd) + de = safe_float(s.get('debt_to_equity')) + if de is not None: + sector_agg[sector]['de'].append(de) + + def avg(lst): + return sum(lst) / len(lst) if lst else None + + sector_data = [ + { + 'sector': k, + 'count': v['count'], + 'mcap': v['mcap'] / 1e12, + 'avg_pe': avg(v['pe']), + 'avg_pb': avg(v['pb']), + 'avg_roe': avg(v['roe']), + 'avg_div': avg(v['div']), + 'avg_ytd': avg(v['ytd']), + 'avg_de': avg(v['de']), + } + for k, v in sorted(sector_agg.items(), key=lambda x: x[1]['mcap'], reverse=True) + ] + + row = 3 + ws.merge_range(row, 0, row, 8, "Sector Overview", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + sector_cols = [ + {'header': 'Sector', 'key': 'sector', 'width': 20}, + {'header': 'Stocks', 'key': 'count', 'width': 8, 'format': self.fmt_int, 'total_function': 'sum'}, + {'header': 'MCap(T)', 'key': 'mcap', 'width': 10, 'format': self.fmt_dec2, 'total_function': 'sum'}, + {'header': 'Avg P/E', 'key': 'avg_pe', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'Avg P/B', 'key': 'avg_pb', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'Avg ROE%', 'key': 'avg_roe', 'width': 10, 'format': self.fmt_pct}, + {'header': 'Avg Yield%', 'key': 'avg_div', 'width': 10, 'format': self.fmt_pct}, + {'header': 'Avg YTD%', 'key': 'avg_ytd', 'width': 10, 'format': self.fmt_pct}, + {'header': 'Avg D/E', 'key': 'avg_de', 'width': 10, 'format': self.fmt_dec2}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, sector_data, sector_cols, 'SectorOverview', total_row=True) + self._add_cond_fmt(ws, data_start, end_row - 2, 5, 'data_bar', bar_color=COLORS['success']) + self._add_cond_fmt(ws, data_start, end_row - 2, 7, 'pos_neg') + + # ========================================================================= + # SHEET: News + # ========================================================================= + def create_news(self): + """Create News & Market Intelligence sheet.""" + ws = self.wb.add_worksheet("News") + + ws.merge_range('A1:I1', "News & Market Intelligence", self.fmt_title) + ws.set_row(0, 26) + + query = """ + SELECT n.*, s.ticker + FROM news n + JOIN stocks s ON n.stock_id = s.id + WHERE n.first_seen >= date(?, '-7 days') + ORDER BY n.published_at DESC + LIMIT 500 + """ + news = self.conn.execute(query, (self.date,)).fetchall() + + row = 3 + ws.merge_range(row, 0, row, 8, "Recent News (Last 7 Days)", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + news_data = [] + for n in news: + news_data.append({ + 'date': n['published_at'][:10] if n['published_at'] else '', + 'ticker': n['ticker'], + 'title': (n['title'] or '')[:70], + 'source': n['source_name'] or '', + 'category': n['category'] or '', + 'sentiment': n['sentiment'] or '', + 'score': safe_float(n['sentiment_score']), + 'critical': 'YES' if n['is_critical'] else '', + 'url': n['url'] or '', + }) + + news_cols = [ + {'header': 'Date', 'key': 'date', 'width': 11}, + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Title', 'key': 'title', 'width': 60}, + {'header': 'Source', 'key': 'source', 'width': 16}, + {'header': 'Category', 'key': 'category', 'width': 12}, + {'header': 'Sentiment', 'key': 'sentiment', 'width': 10}, + {'header': 'Score', 'key': 'score', 'width': 8, 'format': self.fmt_dec2}, + {'header': 'Critical', 'key': 'critical', 'width': 8}, + {'header': 'URL', 'key': 'url', 'width': 50}, + ] + + end_row = self._add_table(ws, row, 0, news_data, news_cols, 'NewsData') + + # Apply sentiment and critical highlighting + for i, n in enumerate(news_data): + cell_row = row + 1 + i + if n['critical'] == 'YES': + ws.write(cell_row, 7, 'YES', self.fmt_bad) + if n['sentiment'] == 'positive': + ws.write(cell_row, 5, n['sentiment'], self.fmt_good) + elif n['sentiment'] == 'negative': + ws.write(cell_row, 5, n['sentiment'], self.fmt_bad) + + ws.freeze_panes(4, 2) + + # ========================================================================= + # SHEET: Sentiment + # ========================================================================= + def create_sentiment(self): + """Create Market Sentiment Analysis sheet.""" + ws = self.wb.add_worksheet("Sentiment") + + ws.merge_range('A1:F1', "Market Sentiment Analysis", self.fmt_title) + ws.set_row(0, 26) + + sentiment_stocks = [s for s in self.stocks if s.get('bullish_pct') is not None] + + # Most Bullish + row = 3 + ws.merge_range(row, 0, row, 5, "Most Bullish Stocks", self.fmt_section_green) + ws.set_row(row, 22) + row += 1 + + sent_data = [] + for s in sentiment_stocks: + sent_data.append({ + 'ticker': s['ticker'], + 'name': s['name'], + 'price': safe_float(s['price']), + 'bull': safe_float(s.get('bullish_pct')), + 'bear': safe_float(s.get('bearish_pct')), + 'net': (safe_float(s.get('bullish_pct'), 0) - safe_float(s.get('bearish_pct'), 0)), + }) + + sent_data.sort(key=lambda x: x['net'], reverse=True) + + sent_cols = [ + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 20}, + {'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_int}, + {'header': 'Bull%', 'key': 'bull', 'width': 10, 'format': self.fmt_pct1}, + {'header': 'Bear%', 'key': 'bear', 'width': 10, 'format': self.fmt_pct1}, + {'header': 'Net', 'key': 'net', 'width': 10, 'format': self.fmt_pct1}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, sent_data, sent_cols, 'SentimentData') + self._add_cond_fmt(ws, data_start, end_row - 1, 3, 'data_bar', bar_color=COLORS['success']) + self._add_cond_fmt(ws, data_start, end_row - 1, 4, 'data_bar', bar_color=COLORS['danger']) + self._add_cond_fmt(ws, data_start, end_row - 1, 5, '3_color_scale') + + # Most Bearish + row = end_row + 2 + ws.merge_range(row, 0, row, 5, "Most Bearish Stocks", self.fmt_section_red) + ws.set_row(row, 22) + row += 1 + + bearish_data = sorted(sent_data, key=lambda x: x['net']) + + self._add_table(ws, row, 0, bearish_data[:20], sent_cols, 'BearishStocks', TABLE_STYLE_RED) + + # ========================================================================= + # SHEET: Earnings + # ========================================================================= + def create_earnings(self): + """Create Earnings History & Performance sheet.""" + ws = self.wb.add_worksheet("Earnings") + + ws.merge_range('A1:K1', "Earnings History & Performance", self.fmt_title) + ws.set_row(0, 26) + + query = """ + SELECT e.*, s.ticker, s.name + FROM earnings e + JOIN stocks s ON e.stock_id = s.id + ORDER BY e.event_date DESC + LIMIT 300 + """ + earnings = self.conn.execute(query).fetchall() + + row = 3 + ws.merge_range(row, 0, row, 9, "Recent Earnings Reports", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + earn_data = [] + for e in earnings: + surprise = safe_float(e['eps_surprise_pct'], 0) + result = 'BEAT' if surprise > 0 else ('MISS' if surprise < 0 else '-') + earn_data.append({ + 'date': e['event_date'] or '', + 'ticker': e['ticker'], + 'name': e['name'], + 'fy': e['fiscal_year'], + 'fq': e['fiscal_quarter'], + 'eps_est': safe_float(e['eps_estimate']), + 'eps_act': safe_float(e['eps_actual']), + 'surprise': safe_float(e['eps_surprise']), + 'surprise_pct': surprise, + 'result': result, + }) + + earn_cols = [ + {'header': 'Date', 'key': 'date', 'width': 11}, + {'header': 'Ticker', 'key': 'ticker', 'width': 8}, + {'header': 'Name', 'key': 'name', 'width': 20}, + {'header': 'FY', 'key': 'fy', 'width': 6, 'format': self.fmt_int}, + {'header': 'FQ', 'key': 'fq', 'width': 5, 'format': self.fmt_int}, + {'header': 'EPS Est', 'key': 'eps_est', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'EPS Act', 'key': 'eps_act', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'Surprise', 'key': 'surprise', 'width': 10, 'format': self.fmt_dec2}, + {'header': 'Surp%', 'key': 'surprise_pct', 'width': 9, 'format': self.fmt_pct}, + {'header': 'Result', 'key': 'result', 'width': 8}, + ] + + data_start = row + 1 + end_row = self._add_table(ws, row, 0, earn_data, earn_cols, 'EarningsData') + self._add_cond_fmt(ws, data_start, end_row - 1, 8, '3_color_scale') + + # Apply result highlighting + for i, e in enumerate(earn_data): + cell_row = row + 1 + i + result = e['result'] + if result == 'BEAT': + fmt = self.fmt_good + elif result == 'MISS': + fmt = self.fmt_bad + else: + fmt = self.fmt_neutral + ws.write(cell_row, 9, result, fmt) + + # Earnings Summary + row = end_row + 2 + ws.merge_range(row, 0, row, 5, "Earnings Summary", self.fmt_section) + ws.set_row(row, 22) + row += 1 + + beats = sum(1 for e in earn_data if e['result'] == 'BEAT') + misses = sum(1 for e in earn_data if e['result'] == 'MISS') + total = len(earn_data) + + summary_data = [ + {'metric': 'Total Reports', 'value': total}, + {'metric': 'Beats', 'value': beats}, + {'metric': 'Misses', 'value': misses}, + {'metric': 'Beat Rate %', 'value': (beats / total * 100) if total else 0}, + ] + + summary_cols = [ + {'header': 'Metric', 'key': 'metric', 'width': 16}, + {'header': 'Value', 'key': 'value', 'width': 12, 'format': self.fmt_dec2}, + ] + + self._add_table(ws, row, 0, summary_data, summary_cols, 'EarningsSummary') + + ws.freeze_panes(4, 2) + + # ========================================================================= + # Generate All Sheets + # ========================================================================= + def generate(self): + """Generate all dashboard sheets.""" + print("Creating Executive Summary...") + self.create_summary() + + print("Creating All Stocks...") + self.create_all_stocks() + + print("Creating Valuation...") + self.create_valuation() + + print("Creating Dividends...") + self.create_dividends() + + print("Creating Financial Health...") + self.create_financial_health() + + print("Creating Momentum...") + self.create_momentum() + + print("Creating Sectors...") + self.create_sectors() + + print("Creating News...") + self.create_news() + + print("Creating Sentiment...") + self.create_sentiment() + + print("Creating Earnings...") + self.create_earnings() + + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +def get_latest_date(conn: sqlite3.Connection) -> str: + """Get latest scrape date from database.""" + cursor = conn.execute("SELECT MAX(scrape_date) FROM price_history") + result = cursor.fetchone() + return result[0] if result[0] else datetime.now().strftime("%Y-%m-%d") + + +def main(): + parser = argparse.ArgumentParser( + description='Create professional Excel dashboard from stock data') + parser.add_argument('--db', required=True, help='SQLite database path') + parser.add_argument('--output', '-o', help='Output Excel file path') + parser.add_argument('--date', help='Scrape date (default: latest)') + 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 + + date = args.date or get_latest_date(conn) + print(f"Using data from: {date}") + + output_path = args.output or f"output/dashboard_{date.replace('-', '')}.xlsx" + + workbook = xlsxwriter.Workbook(output_path, { + 'constant_memory': False, + 'strings_to_urls': True, + }) + + dashboard = ProfessionalDashboard(workbook, conn, date) + dashboard.generate() + + workbook.close() + conn.close() + + print(f"\nDashboard saved to: {output_path}") + return 0 + + +if __name__ == '__main__': + exit(main()) diff --git a/scripts/export_history.py b/scripts/export_history.py new file mode 100644 index 0000000..3c58f08 --- /dev/null +++ b/scripts/export_history.py @@ -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()) diff --git a/scripts/export_simple.py b/scripts/export_simple.py new file mode 100644 index 0000000..f0b61c3 --- /dev/null +++ b/scripts/export_simple.py @@ -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()) diff --git a/scripts/release_bundle.sh b/scripts/release_bundle.sh new file mode 100755 index 0000000..4d8b2f3 --- /dev/null +++ b/scripts/release_bundle.sh @@ -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" diff --git a/tests/go/cli_test.go b/tests/go/cli_test.go new file mode 100644 index 0000000..752cf83 --- /dev/null +++ b/tests/go/cli_test.go @@ -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) + } +} diff --git a/tests/go/live_e2e_test.go b/tests/go/live_e2e_test.go new file mode 100644 index 0000000..3177849 --- /dev/null +++ b/tests/go/live_e2e_test.go @@ -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) + } +} diff --git a/tests/test_export_simple.py b/tests/test_export_simple.py new file mode 100644 index 0000000..3beeeca --- /dev/null +++ b/tests/test_export_simple.py @@ -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() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..153c5be --- /dev/null +++ b/uv.lock @@ -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" }, +]