mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat(rubick): rename project and add production release bundle
This commit is contained in:
commit
fab6cf26eb
33 changed files with 11554 additions and 0 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
BRAVE_API_KEY=your-brave-api-key-here
|
||||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Local env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
.gocache/
|
||||||
|
/bin/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Local binaries
|
||||||
|
rubick
|
||||||
|
msn-scraper
|
||||||
|
news-scraper
|
||||||
|
|
||||||
|
# Generated outputs
|
||||||
|
dist/
|
||||||
|
output/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
output_*.json
|
||||||
|
screener_*.json
|
||||||
|
stocks_*.json
|
||||||
|
test_*.json
|
||||||
|
*.har
|
||||||
|
network_*.txt
|
||||||
|
response.html
|
||||||
49
Makefile
Normal file
49
Makefile
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
APP_NAME := rubick
|
||||||
|
BIN_DIR := bin
|
||||||
|
APP_BIN := $(BIN_DIR)/$(APP_NAME)
|
||||||
|
|
||||||
|
.PHONY: help setup build run test test-go test-py test-live e2e release clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " setup Install Go/Python dependencies"
|
||||||
|
@echo " build Build CLI binary into ./bin"
|
||||||
|
@echo " run Run compiled binary: make run ARGS='msn lookup BBCA'"
|
||||||
|
@echo " test Run Go and Python tests"
|
||||||
|
@echo " test-go Run Go tests"
|
||||||
|
@echo " test-py Run Python tests"
|
||||||
|
@echo " test-live Run live network tests (requires .env and RUN_LIVE_E2E=1)"
|
||||||
|
@echo " e2e Timestamped deterministic e2e run under output/<timestamp>"
|
||||||
|
@echo " release Build distributable bundle under dist/ (VERSION=vX.Y.Z make release)"
|
||||||
|
@echo " clean Remove local build artifacts"
|
||||||
|
|
||||||
|
setup:
|
||||||
|
go mod download
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
build:
|
||||||
|
mkdir -p $(BIN_DIR)
|
||||||
|
go build -o $(APP_BIN) ./cmd/rubick
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./$(APP_BIN) $(ARGS)
|
||||||
|
|
||||||
|
test: test-go test-py
|
||||||
|
|
||||||
|
test-go:
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
test-py:
|
||||||
|
uv run python -m unittest discover -s tests -p 'test_*.py'
|
||||||
|
|
||||||
|
test-live:
|
||||||
|
set -a; source .env; set +a; RUN_LIVE_E2E=1 go test -v ./tests/go -run TestLive
|
||||||
|
|
||||||
|
e2e: build
|
||||||
|
set -a; source .env; set +a; bash scripts/e2e_run.sh ./$(APP_BIN)
|
||||||
|
|
||||||
|
release:
|
||||||
|
bash scripts/release_bundle.sh $(VERSION)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BIN_DIR)
|
||||||
784
README.md
Normal file
784
README.md
Normal file
|
|
@ -0,0 +1,784 @@
|
||||||
|
# Rubick
|
||||||
|
|
||||||
|
Enterprise-ready, multi-language CLI platform for Indonesian market intelligence.
|
||||||
|
|
||||||
|
This repository combines:
|
||||||
|
- high-throughput data collection from MSN Finance (Go),
|
||||||
|
- news discovery from Brave Search (Go),
|
||||||
|
- article-body extraction using `newspaper` (Python),
|
||||||
|
- structured export to JSON/CSV/XLSX (Python),
|
||||||
|
- SQLite persistence for repeatable historical analysis.
|
||||||
|
|
||||||
|
The system is built as one unified CLI with clear subcommands and strongly typed runtime boundaries so you can extend it safely without scattering code.
|
||||||
|
|
||||||
|
## Why This Project Exists
|
||||||
|
|
||||||
|
The goal is to solve a practical workflow problem:
|
||||||
|
- collect Indonesian stock fundamentals and snapshots,
|
||||||
|
- enrich stock context with real article text instead of headline-only snippets,
|
||||||
|
- persist data in a durable local store,
|
||||||
|
- export clean artifacts for analysis, reporting, and downstream automation.
|
||||||
|
|
||||||
|
Common use cases:
|
||||||
|
- analyst daily market snapshots,
|
||||||
|
- watchlist intelligence pipelines,
|
||||||
|
- historical trend extraction and spreadsheet exports,
|
||||||
|
- scheduled ETL feeding BI/ML workloads.
|
||||||
|
|
||||||
|
## Core Capabilities
|
||||||
|
|
||||||
|
- Unified CLI (`rubick`) with domain commands: `msn`, `news`, `export`, `extractor`.
|
||||||
|
- Live stock metadata and multi-endpoint stock fetch pipelines.
|
||||||
|
- Controlled concurrency and rate limiting for network-bound tasks.
|
||||||
|
- Python extractor process connected over Unix socket for fast IPC and language isolation.
|
||||||
|
- SQLite-based persistence for resumable scraping runs.
|
||||||
|
- Multiple export formats for both machine and human consumption.
|
||||||
|
- Regression tests and live API e2e test coverage.
|
||||||
|
|
||||||
|
## High-Level Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U[User / Scheduler] --> C[Unified CLI<br/>Go]
|
||||||
|
|
||||||
|
C --> M[MSN Workflows<br/>Go]
|
||||||
|
C --> N[News Workflow<br/>Go]
|
||||||
|
C --> E[Export Workflow<br/>Python]
|
||||||
|
|
||||||
|
M --> MSNAPI[MSN Finance APIs]
|
||||||
|
M --> DB[(SQLite)]
|
||||||
|
|
||||||
|
N --> BRAVE[Brave News API]
|
||||||
|
N --> FETCH[HTML Fetcher]
|
||||||
|
FETCH --> IPC[Unix Socket IPC]
|
||||||
|
IPC --> PX[Python Extractor<br/>newspaper]
|
||||||
|
N --> OUTJSON[News JSON Output]
|
||||||
|
|
||||||
|
E --> DB
|
||||||
|
E --> XLSX[XLSX / CSV / JSON Export Files]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Data Flow
|
||||||
|
|
||||||
|
### 1. MSN bulk ingestion (`msn fetch-all`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant CLI as Go CLI
|
||||||
|
participant DB as SQLite
|
||||||
|
participant MSN as MSN API
|
||||||
|
|
||||||
|
User->>CLI: msn fetch-all --index idx30 --db output/run/stocks.db
|
||||||
|
CLI->>DB: start scrape_run + init progress
|
||||||
|
loop each stock (concurrency workers)
|
||||||
|
CLI->>MSN: fetch stock endpoints
|
||||||
|
MSN-->>CLI: payloads + statuses
|
||||||
|
CLI->>DB: save stock + history + news + ratios
|
||||||
|
CLI->>DB: update progress row
|
||||||
|
end
|
||||||
|
CLI->>DB: complete scrape_run
|
||||||
|
CLI-->>User: progress + summary
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. News enrichment (`news`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant CLI as Go CLI
|
||||||
|
participant Brave as Brave API
|
||||||
|
participant Web as Article URL
|
||||||
|
participant Py as Python Extractor
|
||||||
|
participant Out as JSON File
|
||||||
|
|
||||||
|
User->>CLI: news "IHSG" --count 20
|
||||||
|
CLI->>Brave: search news
|
||||||
|
Brave-->>CLI: URL list
|
||||||
|
CLI->>Py: start extractor process + socket pool
|
||||||
|
loop each URL (workers)
|
||||||
|
CLI->>Web: GET page HTML
|
||||||
|
CLI->>Py: extract(url, html)
|
||||||
|
Py-->>CLI: text/status
|
||||||
|
end
|
||||||
|
CLI->>Out: write enriched JSON
|
||||||
|
CLI-->>User: extraction summary
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Structure and Folder Contracts
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── cmd/
|
||||||
|
│ └── rubick/ # canonical binary entrypoint
|
||||||
|
├── internal/
|
||||||
|
│ └── cli/ # root command router + command implementations
|
||||||
|
├── msn/ # MSN domain library: client, db, types, limiter, stock universe
|
||||||
|
├── scripts/ # Python export scripts
|
||||||
|
├── tests/
|
||||||
|
│ ├── go/ # Go CLI regression + live network e2e tests
|
||||||
|
│ └── test_export_simple.py # Python exporter tests
|
||||||
|
├── extractor.py # Python unix-socket extraction service
|
||||||
|
├── main.go # compatibility entrypoint for local dev only
|
||||||
|
├── pyproject.toml # Python deps and tooling
|
||||||
|
├── go.mod / go.sum # Go module deps
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Folder responsibilities:
|
||||||
|
|
||||||
|
| Path | Responsibility | Extension Rule |
|
||||||
|
|---|---|---|
|
||||||
|
| `cmd/rubick` | binary entrypoint only | keep thin; no business logic |
|
||||||
|
| `internal/cli` | command parsing, orchestration, runtime coordination | add new command handlers here |
|
||||||
|
| `msn` | data-source domain package and persistence internals | keep API/service-specific logic here |
|
||||||
|
| `scripts` | Python export implementations | each exporter should be independent CLI |
|
||||||
|
| `tests/go` | end-to-end and CLI behavior tests | test via public CLI behavior, not internals |
|
||||||
|
| `tests` | Python unit tests | one test module per script/component |
|
||||||
|
|
||||||
|
## Language Boundary (Go + Python)
|
||||||
|
|
||||||
|
Go is used for:
|
||||||
|
- CLI UX,
|
||||||
|
- concurrency and throughput,
|
||||||
|
- networking and orchestration,
|
||||||
|
- SQLite ingestion flows.
|
||||||
|
|
||||||
|
Python is used for:
|
||||||
|
- article extraction where ecosystem libraries are stronger,
|
||||||
|
- export formatting (especially XLSX convenience).
|
||||||
|
|
||||||
|
IPC contract:
|
||||||
|
- transport: Unix domain socket,
|
||||||
|
- framing: 4-byte big-endian length + JSON payload,
|
||||||
|
- request shape: `{ "url": string, "html": string }`,
|
||||||
|
- response shape: `{ "text": string, "status": "ok|failed", "error"?: string }`.
|
||||||
|
|
||||||
|
Reliability safeguards already implemented:
|
||||||
|
- per-run unique socket path to prevent collision,
|
||||||
|
- deadline-aware socket calls,
|
||||||
|
- full-frame read/write (`ReadFull` semantics),
|
||||||
|
- unhealthy pooled connections are closed/replaced,
|
||||||
|
- extractor process group termination and socket file cleanup.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go `1.25+`
|
||||||
|
- Python `3.12+`
|
||||||
|
- `uv` for Python environment/dependency management
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
uv sync
|
||||||
|
mkdir -p bin
|
||||||
|
go build -o bin/rubick ./cmd/rubick
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Create `.env` for Brave-powered news commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env
|
||||||
|
# BRAVE_API_KEY=your_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# production/dev standard
|
||||||
|
./bin/rubick <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Developer-only shortcut (not recommended for operational runbooks):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/rubick <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command Catalog
|
||||||
|
|
||||||
|
Root commands:
|
||||||
|
|
||||||
|
| Command | Purpose | Output |
|
||||||
|
|---|---|---|
|
||||||
|
| `msn` | MSN finance workflows (`screener`, `fetch`, `fetch-all`, `lookup`) | JSON + SQLite |
|
||||||
|
| `news` | Brave search + article extraction | JSON |
|
||||||
|
| `export` | Python exports (`dashboard`, `history`, `simple`) | XLSX/CSV/JSON |
|
||||||
|
| `extractor` | run extractor service directly (advanced/debug) | socket server |
|
||||||
|
|
||||||
|
## Command Reference (Detailed)
|
||||||
|
|
||||||
|
### `msn screener`
|
||||||
|
|
||||||
|
Find stocks using preset screener filters.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick msn screener --region id --filter top-performers --limit 20 --output output/screener.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Default | Validation | What It Does |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `--region` | region code (`id`) | `id` | non-empty | target market region for screener query |
|
||||||
|
| `--filter` | one preset from list below | `large-cap` | must map to known filter | selects screener criteria |
|
||||||
|
| `--limit` | integer `>= 1` | `50` | strict integer and min bound | max rows returned |
|
||||||
|
| `--output`, `-o` | file path | `screener_YYYYMMDD.json` | writable path | output JSON location |
|
||||||
|
|
||||||
|
Supported filter presets:
|
||||||
|
- `top-performers`
|
||||||
|
- `worst-performers`
|
||||||
|
- `high-dividend`
|
||||||
|
- `low-pe`
|
||||||
|
- `52w-high`
|
||||||
|
- `52w-low`
|
||||||
|
- `high-volume`
|
||||||
|
- `large-cap`
|
||||||
|
|
||||||
|
Expected output example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filter": "top-performers",
|
||||||
|
"region": "id",
|
||||||
|
"generated_at": "2026-03-06T02:39:47Z",
|
||||||
|
"total": 20,
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"id": "bn91jc",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"name": "Bank Cntrl Asia",
|
||||||
|
"price": 9000.0,
|
||||||
|
"price_change_pct": 1.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Variations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Small deterministic sample
|
||||||
|
./bin/rubick msn screener --region id --filter large-cap --limit 3 -o output/run/screener_top3.json
|
||||||
|
|
||||||
|
# Alternate filter
|
||||||
|
./bin/rubick msn screener --filter low-pe --limit 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### `msn lookup`
|
||||||
|
|
||||||
|
Resolve ticker symbols to internal MSN IDs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick msn lookup BBCA BBRI TLKM
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Input | Expected Value | What It Does | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| positional tickers | uppercase ticker symbols | maps tickers to static IDX dictionary | table printed to stdout |
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ticker MSN ID Company Name
|
||||||
|
--------------------------------------------------
|
||||||
|
BBCA bn91jc Bank Cntrl Asia
|
||||||
|
BBRI bn6wly Bank Rakyat Indonesia
|
||||||
|
TLKM bn4k6h Telkom Indonesia
|
||||||
|
--------------------------------------------------
|
||||||
|
Found 3/3 tickers
|
||||||
|
```
|
||||||
|
|
||||||
|
### `msn fetch`
|
||||||
|
|
||||||
|
Fetch comprehensive stock data for specific IDs/tickers or screener input.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick msn fetch --tickers BBCA,BBRI,TLKM --concurrency 5 --output output/fetch.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Default | Validation | What It Does |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `--input` | path to screener JSON | none | file must exist and parse | imports stock IDs from screener output |
|
||||||
|
| `--ids` | comma-separated IDs | none | non-empty entries | fetch by explicit MSN IDs |
|
||||||
|
| `--tickers` | comma-separated tickers | none | unknown tickers skipped with warning | resolves ticker to MSN ID |
|
||||||
|
| `--concurrency` | integer `>= 1` | `5` | strict integer and min bound | worker parallelism |
|
||||||
|
| `--output`, `-o` | file path | `stocks_YYYYMMDD.json` | writable path | output JSON |
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- you must provide at least one source of IDs (`--input`, `--ids`, `--tickers`),
|
||||||
|
- duplicate IDs are deduplicated before fetch,
|
||||||
|
- fetch status is tracked per API subsection.
|
||||||
|
|
||||||
|
Expected output (truncated):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"generated_at": "2026-03-06T02:40:00Z",
|
||||||
|
"total": 2,
|
||||||
|
"stocks": [
|
||||||
|
{
|
||||||
|
"id": "bn91jc",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"fetch_status": {
|
||||||
|
"quote": "ok",
|
||||||
|
"profile": "ok",
|
||||||
|
"financials": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Variations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# By explicit IDs
|
||||||
|
./bin/rubick msn fetch --ids bn91jc,bn6wly -o output/run/fetch_ids.json
|
||||||
|
|
||||||
|
# From screener output
|
||||||
|
./bin/rubick msn fetch --input output/run/screener_top3.json --concurrency 2 -o output/run/fetch_from_screener.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### `msn fetch-all`
|
||||||
|
|
||||||
|
Bulk ingest index constituents into SQLite with progress tracking.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick msn fetch-all --index idx30 --db output/stocks.db --rps 20 --delay 100-500 --concurrency 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Default | Validation | What It Does |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `--db` | sqlite file path | `output/stocks.db` | writable path | target DB |
|
||||||
|
| `--index` | `all` / `lq45` / `idx30` / `idx80` | `all` | must be known, unknown falls back to all | stock universe scope |
|
||||||
|
| `--proxy` | proxy URL | empty | URL format checked by client path | route requests through proxy |
|
||||||
|
| `--concurrency` | integer `>= 1` | `5` | strict integer | worker count |
|
||||||
|
| `--rps` | float `> 0` | `25` | strict positive | global request throttling |
|
||||||
|
| `--delay` | `min-max` milliseconds | `100-500` | `0 <= min <= max` | jitter between requests |
|
||||||
|
| `--retry` | integer `>= 0` | `2` | strict integer | retry attempts per stock |
|
||||||
|
| `--limit` | integer `>= 0` | `0` (all) | strict integer | process first N stocks |
|
||||||
|
| `--resume` | no value | off | flag | continue incomplete run |
|
||||||
|
|
||||||
|
What it writes:
|
||||||
|
- `stocks`
|
||||||
|
- `price_history`
|
||||||
|
- `ratios_history`
|
||||||
|
- `news`
|
||||||
|
- `sentiment_history`
|
||||||
|
- `scrape_runs`
|
||||||
|
- `scrape_progress`
|
||||||
|
|
||||||
|
Expected terminal progress:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fetch-All Configuration:
|
||||||
|
Database: output/run/stocks.db
|
||||||
|
Index: idx30
|
||||||
|
Concurrency: 2 workers
|
||||||
|
Rate limit: 10.0 req/sec
|
||||||
|
Delay: 100-150 ms
|
||||||
|
Started run #1
|
||||||
|
Pending: 3 stocks to process
|
||||||
|
[1/3] ADRO - 8 APIs succeeded
|
||||||
|
[2/3] ASII - 8 APIs succeeded
|
||||||
|
[3/3] GOTO - 8 APIs succeeded
|
||||||
|
=== Run #1 Completed ===
|
||||||
|
```
|
||||||
|
|
||||||
|
Variations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deterministic mini run for test
|
||||||
|
./bin/rubick msn fetch-all --index idx30 --limit 3 --db output/run/stocks.db --rps 10 --delay 100-150 --concurrency 2
|
||||||
|
|
||||||
|
# Resume interrupted batch
|
||||||
|
./bin/rubick msn fetch-all --index idx80 --db output/prod/stocks.db --resume
|
||||||
|
```
|
||||||
|
|
||||||
|
### `news`
|
||||||
|
|
||||||
|
Search Brave News and extract full article text from each result.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick news "IHSG stock market" --from 2026-03-01 --to 2026-03-05 --count 20 --concurrency 10 --output output/news.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Default | Validation | What It Does |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| positional `<query>` | free-text query | required | non-empty | base search query |
|
||||||
|
| `--from` | `YYYY-MM-DD` | now - 7d | valid date | start date |
|
||||||
|
| `--to` | `YYYY-MM-DD` | today | valid date and `from <= to` | end date |
|
||||||
|
| `--count` | integer `>= 1` | `20` | strict integer | result count requested |
|
||||||
|
| `--concurrency` | integer `>= 1` | `10` | strict integer | worker count for fetch/extract |
|
||||||
|
| `--output`, `-o` | json path | `output_YYYYMMDD.json` | writable path | final enriched output |
|
||||||
|
| `--stock` | no value | off | flag | transforms comma terms into IDX-centric boolean query |
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
| Variable | Required | Used By | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BRAVE_API_KEY` | yes for `news` | Go brave client | authenticate Brave Search API calls |
|
||||||
|
|
||||||
|
Expected output sample:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "IHSG",
|
||||||
|
"generated_at": "2026-03-06T02:39:47Z",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"title": "...",
|
||||||
|
"url": "https://...",
|
||||||
|
"description": "...",
|
||||||
|
"page_age": "1d",
|
||||||
|
"text": "full extracted body text ...",
|
||||||
|
"fetch_status": "ok",
|
||||||
|
"extract_status": "ok"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Variations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plain query
|
||||||
|
./bin/rubick news IHSG --count 5 -o output/run/news_plain.json
|
||||||
|
|
||||||
|
# Stock-mode query builder
|
||||||
|
./bin/rubick news "BBCA,Bank Central Asia" --stock --from 2026-03-01 --to 2026-03-05 --count 10 -o output/run/news_stock.json
|
||||||
|
|
||||||
|
# Lower concurrency for constrained hosts
|
||||||
|
./bin/rubick news IHSG --count 10 --concurrency 2
|
||||||
|
```
|
||||||
|
|
||||||
|
### `export dashboard`
|
||||||
|
|
||||||
|
Create dashboard-oriented workbook from SQLite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Required | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--db` | SQLite path | yes | source database |
|
||||||
|
| `--output` | `.xlsx` path | yes | generated workbook |
|
||||||
|
|
||||||
|
### `export history`
|
||||||
|
|
||||||
|
Create history-oriented workbook from SQLite.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick export history --db output/stocks.db --output output/history.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Required | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--db` | SQLite path | yes | source database |
|
||||||
|
| `--output` | `.xlsx` path | yes | generated workbook |
|
||||||
|
|
||||||
|
### `export simple`
|
||||||
|
|
||||||
|
Lightweight table export for automation and quick inspection.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick export simple --db output/stocks.db --format csv --output output/simple_csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Required | What It Does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--db` | SQLite path | yes | source DB |
|
||||||
|
| `--format` | `json` / `csv` / `xlsx` | yes | output encoding |
|
||||||
|
| `--output`, `-o` | directory (json/csv) or file (xlsx) | yes | destination |
|
||||||
|
| `--tables` | comma-separated table list | no | export subset |
|
||||||
|
|
||||||
|
Default table set:
|
||||||
|
- `stocks`
|
||||||
|
- `price_history`
|
||||||
|
- `ratios_history`
|
||||||
|
- `news`
|
||||||
|
- `sentiment_history`
|
||||||
|
- `scrape_runs`
|
||||||
|
- `scrape_progress`
|
||||||
|
|
||||||
|
Variations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JSON folder export
|
||||||
|
./bin/rubick export simple --db output/run/stocks.db --format json --output output/run/simple_json
|
||||||
|
|
||||||
|
# XLSX single workbook
|
||||||
|
./bin/rubick export simple --db output/run/stocks.db --format xlsx --output output/run/simple.xlsx
|
||||||
|
|
||||||
|
# Table subset
|
||||||
|
./bin/rubick export simple --db output/run/stocks.db --format csv --tables stocks,news --output output/run/simple_subset
|
||||||
|
```
|
||||||
|
|
||||||
|
### `extractor` (advanced)
|
||||||
|
|
||||||
|
Run Python extractor server directly for debugging/local integration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick extractor --socket /tmp/extractor.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
|
||||||
|
| Flag | Expected Value | Required | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--socket` | unix socket path | yes | bind location |
|
||||||
|
|
||||||
|
## Deterministic End-to-End Run (Timestamped)
|
||||||
|
|
||||||
|
Use this for repeatable smoke validation and artifact capture:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TS=$(date +%Y%m%d-%H%M%S)
|
||||||
|
mkdir -p output/$TS
|
||||||
|
|
||||||
|
# 1) Small stock ingestion
|
||||||
|
./bin/rubick msn fetch-all --index idx30 --limit 3 --db output/$TS/stocks.db --rps 10 --delay 100-150 --concurrency 2
|
||||||
|
|
||||||
|
# 2) News extraction
|
||||||
|
./bin/rubick news IHSG --from 2026-03-01 --to 2026-03-05 --count 2 --concurrency 2 --output output/$TS/news.json
|
||||||
|
|
||||||
|
# 3) Exports
|
||||||
|
./bin/rubick export simple --db output/$TS/stocks.db --format json --output output/$TS/simple_json
|
||||||
|
./bin/rubick export simple --db output/$TS/stocks.db --format csv --output output/$TS/simple_csv
|
||||||
|
./bin/rubick export simple --db output/$TS/stocks.db --format xlsx --output output/$TS/simple.xlsx
|
||||||
|
./bin/rubick export dashboard --db output/$TS/stocks.db --output output/$TS/dashboard.xlsx
|
||||||
|
./bin/rubick export history --db output/$TS/stocks.db --output output/$TS/history.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Go tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full suite (includes tests/go)
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Focused CLI regression + live tests
|
||||||
|
go test -v ./tests/go
|
||||||
|
```
|
||||||
|
|
||||||
|
For CI and local repeatability, prefer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Live network e2e tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a; source .env; set +a
|
||||||
|
RUN_LIVE_E2E=1 go test -v ./tests/go -run TestLive
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior note:
|
||||||
|
- tests treat transient network errors (DNS timeout, temporary connectivity, 429) as skippable for live-only coverage.
|
||||||
|
|
||||||
|
### Python tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m unittest discover -s tests -p 'test_*.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Release Bundle
|
||||||
|
|
||||||
|
Create a distributable artifact that includes:
|
||||||
|
- compiled `rubick` binary,
|
||||||
|
- `extractor.py`,
|
||||||
|
- Python export scripts (`scripts/export_*.py`),
|
||||||
|
- `pyproject.toml` and `uv.lock`,
|
||||||
|
- `.env.example`, `README.md`, and install instructions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# auto version from git tag/commit
|
||||||
|
make release
|
||||||
|
|
||||||
|
# explicit version
|
||||||
|
VERSION=v1.0.0 make release
|
||||||
|
```
|
||||||
|
|
||||||
|
Output artifacts:
|
||||||
|
- `dist/rubick_<version>_<os>_<arch>/`
|
||||||
|
- `dist/rubick_<version>_<os>_<arch>.tar.gz`
|
||||||
|
- `dist/rubick_<version>_<os>_<arch>.zip`
|
||||||
|
|
||||||
|
Bundle runtime setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dist/rubick_<version>_<os>_<arch>
|
||||||
|
uv sync --frozen
|
||||||
|
./bin/rubick --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build and Run Profiles
|
||||||
|
|
||||||
|
| Profile | Command | When To Use |
|
||||||
|
|---|---|---|
|
||||||
|
| Local developer iteration | `go run ./cmd/rubick ...` | rapid code changes before rebuilding |
|
||||||
|
| Normal local/CI usage | `./bin/rubick ...` | default path for scripts and tests |
|
||||||
|
| Release artifact | `go build -o bin/rubick ./cmd/rubick` | reproducible deployable binary |
|
||||||
|
|
||||||
|
## Automation Targets
|
||||||
|
|
||||||
|
`Makefile` commands:
|
||||||
|
|
||||||
|
| Target | Action |
|
||||||
|
|---|---|
|
||||||
|
| `make setup` | install Go + Python dependencies |
|
||||||
|
| `make build` | compile `bin/rubick` |
|
||||||
|
| `make run ARGS='...'` | run compiled binary with arguments |
|
||||||
|
| `make test` | run Go + Python tests |
|
||||||
|
| `make test-live` | run live e2e tests with `.env` |
|
||||||
|
| `make e2e` | run deterministic timestamped end-to-end workflow |
|
||||||
|
|
||||||
|
## Operational Characteristics
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- `msn fetch-all` throughput controlled by:
|
||||||
|
- worker count (`--concurrency`),
|
||||||
|
- global RPS (`--rps`),
|
||||||
|
- jitter (`--delay`),
|
||||||
|
- retries (`--retry`).
|
||||||
|
- `news` throughput controlled by:
|
||||||
|
- Brave result count (`--count`),
|
||||||
|
- concurrent fetch/extract workers (`--concurrency`).
|
||||||
|
|
||||||
|
Tuning guidance:
|
||||||
|
- start conservative (`--concurrency 2`, `--rps 10`) and increase gradually,
|
||||||
|
- use lower concurrency on unstable networks,
|
||||||
|
- avoid high parallelism if extractor host is resource-constrained.
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
|
||||||
|
- resumable runs via `scrape_runs` and `scrape_progress`,
|
||||||
|
- no socket-path collision due to per-process unique socket names,
|
||||||
|
- pooled socket self-healing on I/O error,
|
||||||
|
- explicit process-group shutdown for extractor.
|
||||||
|
|
||||||
|
### Failure Modes and Recovery
|
||||||
|
|
||||||
|
| Symptom | Likely Cause | Recovery |
|
||||||
|
|---|---|---|
|
||||||
|
| `failed to search` in `news` | missing/invalid `BRAVE_API_KEY` or API/network issue | check `.env`, retry with smaller `--count` |
|
||||||
|
| extractor startup timeout | Python env/deps not ready | run `uv sync`, retry command |
|
||||||
|
| `failed to open database` | invalid DB path/permissions | use writable path under `output/` |
|
||||||
|
| high fail count in `fetch-all` | API throttling/network instability | reduce `--concurrency`, reduce `--rps`, increase retries |
|
||||||
|
|
||||||
|
## Technical Implementation Notes
|
||||||
|
|
||||||
|
### Internal command model
|
||||||
|
|
||||||
|
- `cmd/rubick/main.go` delegates to `internal/cli.Run(args)`.
|
||||||
|
- each top-level command has dedicated handler logic.
|
||||||
|
- help-path exit code is `0`; invalid usage and command errors return non-zero.
|
||||||
|
|
||||||
|
### Data model and storage
|
||||||
|
|
||||||
|
SQLite tables persist both point-in-time and historical views. Export scripts consume the same DB, which makes the workflow reproducible and scriptable.
|
||||||
|
|
||||||
|
### Security and secret handling
|
||||||
|
|
||||||
|
- never hardcode API keys,
|
||||||
|
- keep `.env` out of source control,
|
||||||
|
- rotate `BRAVE_API_KEY` periodically,
|
||||||
|
- prefer environment injection in CI/CD rather than plaintext files.
|
||||||
|
|
||||||
|
## Extending the Codebase
|
||||||
|
|
||||||
|
### Add a new data source command
|
||||||
|
|
||||||
|
1. Add a new handler in `internal/cli`.
|
||||||
|
2. Register command routing in `Run(args)`.
|
||||||
|
3. Keep source-specific logic in a dedicated package (similar to `msn/`).
|
||||||
|
4. Add CLI regression tests in `tests/go`.
|
||||||
|
5. Add docs + deterministic sample in README.
|
||||||
|
|
||||||
|
### Add a new Python-assisted feature
|
||||||
|
|
||||||
|
1. Put Python implementation in `scripts/` or standalone server file.
|
||||||
|
2. Keep wire contract small and explicit if IPC is needed.
|
||||||
|
3. Add input/output schema tests in `tests/`.
|
||||||
|
4. Expose feature through one unified CLI command, not ad-hoc scripts.
|
||||||
|
|
||||||
|
### Go-only vs Python-only decisions
|
||||||
|
|
||||||
|
Use Go when:
|
||||||
|
- you need high-concurrency network orchestration,
|
||||||
|
- strong type-safety and binary distribution matter.
|
||||||
|
|
||||||
|
Use Python when:
|
||||||
|
- library ecosystem is materially better for the task,
|
||||||
|
- rapid iteration of parsing/formatting logic is needed.
|
||||||
|
|
||||||
|
Hybrid rule:
|
||||||
|
- keep orchestration in Go,
|
||||||
|
- isolate Python to specialized components with strict IPC contracts,
|
||||||
|
- document protocol and lifecycle clearly.
|
||||||
|
|
||||||
|
## Example Production-Like Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TS=$(date +%Y%m%d-%H%M%S)
|
||||||
|
BASE=output/$TS
|
||||||
|
mkdir -p $BASE
|
||||||
|
|
||||||
|
# Collect core market dataset
|
||||||
|
./bin/rubick msn fetch-all --index idx80 --db $BASE/stocks.db --rps 15 --delay 150-400 --concurrency 4
|
||||||
|
|
||||||
|
# Enrich with news for macro keyword
|
||||||
|
./bin/rubick news "IHSG OR Jakarta Composite Index" --from 2026-03-01 --to 2026-03-06 --count 30 --concurrency 6 --output $BASE/news_macro.json
|
||||||
|
|
||||||
|
# Export for analyst consumption
|
||||||
|
./bin/rubick export dashboard --db $BASE/stocks.db --output $BASE/dashboard.xlsx
|
||||||
|
./bin/rubick export history --db $BASE/stocks.db --output $BASE/history.xlsx
|
||||||
|
./bin/rubick export simple --db $BASE/stocks.db --format csv --output $BASE/csv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| MSN ID | internal identifier used by MSN endpoints |
|
||||||
|
| Screener | preset query to select stocks by criteria |
|
||||||
|
| Fetch-all run | bulk ingestion execution tracked in DB |
|
||||||
|
| Extractor | Python process that converts raw HTML to article text |
|
||||||
|
| Stock mode query | boolean query generated from comma-separated stock terms |
|
||||||
|
|
||||||
|
## License / Internal Policy
|
||||||
|
|
||||||
|
Add your repository license and internal data-usage policy here if this project is used in production or shared environments.
|
||||||
87
TEST_REPORT.md
Normal file
87
TEST_REPORT.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Unified CLI Test Report
|
||||||
|
|
||||||
|
Date: 2026-03-06
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Unified CLI commands tested: `msn`, `news`, `export`, `extractor`
|
||||||
|
- Parser/validation tested with valid and invalid arguments
|
||||||
|
- Error handling standardized: command modules return errors; root handles exit code
|
||||||
|
- Remaining hard exit: only top-level `main()` calls `os.Exit(runRoot(...))`
|
||||||
|
|
||||||
|
## Command Matrix (Representative)
|
||||||
|
|
||||||
|
### Root
|
||||||
|
|
||||||
|
- `go run .` -> usage printed, exit `1`
|
||||||
|
- `go run . --help` -> usage printed, exit `1`
|
||||||
|
- `go run . unknown` -> usage + `unknown command`, exit `1`
|
||||||
|
|
||||||
|
### MSN
|
||||||
|
|
||||||
|
- `go run . msn --help` -> MSN usage, exit `1`
|
||||||
|
- `go run . msn badcmd` -> error unknown subcommand, exit `1`
|
||||||
|
- `go run . msn lookup BBCA TLKM XXXX` -> success, resolves BBCA/TLKM, unknown marked not found, exit `0`
|
||||||
|
|
||||||
|
#### `msn screener`
|
||||||
|
- `--help` -> screener usage, exit `0`
|
||||||
|
- `--region` (missing value) -> error, exit `1`
|
||||||
|
- `--limit nope` -> parse error, exit `1`
|
||||||
|
- `--filter invalid` -> validation error, exit `1`
|
||||||
|
- valid invocation attempted -> DNS failure to `assets.msn.com` in this environment, exit `1`
|
||||||
|
|
||||||
|
#### `msn fetch`
|
||||||
|
- `--help` -> fetch usage, exit `0`
|
||||||
|
- no id source -> validation error, exit `1`
|
||||||
|
- `--input` missing value -> error, exit `1`
|
||||||
|
- `--tickers` missing value -> error, exit `1`
|
||||||
|
- `--concurrency nope` -> parse error, exit `1`
|
||||||
|
- `--input` missing file -> FS error, exit `1`
|
||||||
|
- valid with tickers -> success, output `/tmp/fetch_tickers.json`, exit `0`
|
||||||
|
- valid with ids -> success, output `/tmp/fetch_ids.json`, exit `0`
|
||||||
|
|
||||||
|
#### `msn fetch-all`
|
||||||
|
- `--help` -> fetch-all usage, exit `0`
|
||||||
|
- `--db` missing value -> error, exit `1`
|
||||||
|
- `--delay oops` -> format error, exit `1`
|
||||||
|
- `--rps nope` -> parse error, exit `1`
|
||||||
|
- valid `--index weird --limit 1` -> fallback to all, success, `/tmp/fetchall_weird.db`, exit `0`
|
||||||
|
- valid `--index idx30 --limit 1` -> success, `/tmp/fetchall_idx30.db`, exit `0`
|
||||||
|
|
||||||
|
### News
|
||||||
|
|
||||||
|
- `go run . news --help` -> news usage, exit `1`
|
||||||
|
- valid news request attempted -> fails with missing `BRAVE_API_KEY` in current shell env, exit `1`
|
||||||
|
- `--from bad-date` -> parse error, exit `1`
|
||||||
|
- `--to bad-date` -> parse error, exit `1`
|
||||||
|
- `--count nope` -> parse error, exit `1`
|
||||||
|
- `--concurrency nope` -> parse error, exit `1`
|
||||||
|
- `--output` missing value -> error, exit `1`
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
- `go run . export --help` -> usage, exit `1`
|
||||||
|
- `go run . export badtarget` -> validation error, exit `1`
|
||||||
|
- `go run . export dashboard --db /tmp/fetchall_idx30.db --output /tmp/dashboard_refactor.xlsx` -> success, exit `0`
|
||||||
|
- `go run . export history --db /tmp/fetchall_idx30.db --output /tmp/history_refactor.xlsx` -> success, exit `0`
|
||||||
|
|
||||||
|
### Extractor
|
||||||
|
|
||||||
|
- `go run . extractor` -> usage, exit `1`
|
||||||
|
- `go run . extractor --help` -> usage, exit `1`
|
||||||
|
|
||||||
|
## Produced Artifacts
|
||||||
|
|
||||||
|
- `/tmp/fetch_one.json`
|
||||||
|
- `/tmp/fetch_tickers.json`
|
||||||
|
- `/tmp/fetch_ids.json`
|
||||||
|
- `/tmp/fetchall_weird.db`
|
||||||
|
- `/tmp/fetchall_idx30.db`
|
||||||
|
- `/tmp/dashboard_refactor.xlsx`
|
||||||
|
- `/tmp/history_refactor.xlsx`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Network/API behavior is environment-dependent (DNS and API key availability).
|
||||||
|
- `news` command requires `BRAVE_API_KEY` in the running shell environment.
|
||||||
|
- CLI behavior now consistently reports parse/validation/runtime errors without deep `log.Fatal` exits.
|
||||||
165
extractor.py
Normal file
165
extractor.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Article text extractor using newspaper4k.
|
||||||
|
Runs as an asyncio Unix socket server for IPC with Go.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from newspaper import Article
|
||||||
|
|
||||||
|
# Suppress asyncio warnings on forced shutdown
|
||||||
|
warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*coroutine.*")
|
||||||
|
|
||||||
|
# Protocol: 4-byte length prefix (big-endian uint32) + JSON payload
|
||||||
|
HEADER_SIZE = 4
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_text(url: str, html: str) -> dict:
|
||||||
|
"""Extract article text from HTML using newspaper4k."""
|
||||||
|
try:
|
||||||
|
article = Article(url)
|
||||||
|
article.download(input_html=html)
|
||||||
|
article.parse()
|
||||||
|
|
||||||
|
text = article.text.strip()
|
||||||
|
if not text:
|
||||||
|
return {"text": "", "status": "failed", "error": "empty_text"}
|
||||||
|
|
||||||
|
return {"text": text, "status": "ok"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"text": "", "status": "failed", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def read_message(reader: asyncio.StreamReader) -> dict | None:
|
||||||
|
"""Read a length-prefixed JSON message."""
|
||||||
|
header = await reader.readexactly(HEADER_SIZE)
|
||||||
|
if not header:
|
||||||
|
return None
|
||||||
|
|
||||||
|
length = struct.unpack(">I", header)[0]
|
||||||
|
data = await reader.readexactly(length)
|
||||||
|
return json.loads(data.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
async def write_message(writer: asyncio.StreamWriter, msg: dict) -> None:
|
||||||
|
"""Write a length-prefixed JSON message."""
|
||||||
|
data = json.dumps(msg).encode("utf-8")
|
||||||
|
header = struct.pack(">I", len(data))
|
||||||
|
writer.write(header + data)
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
|
"""Handle a single client connection."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await read_message(reader)
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if msg is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for shutdown command
|
||||||
|
if msg.get("command") == "shutdown":
|
||||||
|
await write_message(writer, {"status": "ok", "message": "shutting_down"})
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract text
|
||||||
|
url = msg.get("url", "")
|
||||||
|
html = msg.get("html", "")
|
||||||
|
result = await extract_text(url, html)
|
||||||
|
await write_message(writer, result)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass # Silently handle errors on shutdown
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def run_server(socket_path: str):
|
||||||
|
"""Run the Unix socket server."""
|
||||||
|
# Remove existing socket file if present
|
||||||
|
socket_file = Path(socket_path)
|
||||||
|
if socket_file.exists():
|
||||||
|
socket_file.unlink()
|
||||||
|
|
||||||
|
server = await asyncio.start_unix_server(handle_client, path=socket_path)
|
||||||
|
|
||||||
|
# Signal readiness to parent process
|
||||||
|
print(f"READY:{socket_path}", flush=True)
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
try:
|
||||||
|
await server.serve_forever()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cleanup socket file
|
||||||
|
if socket_file.exists():
|
||||||
|
socket_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Suppress task destroyed warnings
|
||||||
|
import logging
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Article text extractor server")
|
||||||
|
parser.add_argument("--socket", required=True, help="Unix socket path")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
socket_file = Path(args.socket)
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
if socket_file.exists():
|
||||||
|
socket_file.unlink()
|
||||||
|
|
||||||
|
# Handle signals for graceful shutdown
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, loop.stop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(run_server(args.socket))
|
||||||
|
except (KeyboardInterrupt, RuntimeError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Cancel all pending tasks
|
||||||
|
pending = asyncio.all_tasks(loop)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Run until all tasks are cancelled
|
||||||
|
if pending:
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||||
|
except RuntimeError:
|
||||||
|
# Event loop can be stopped by signal handlers during shutdown.
|
||||||
|
pass
|
||||||
|
|
||||||
|
loop.close()
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
go.mod
Normal file
27
go.mod
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
module rubick
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/enetx/g v1.0.210
|
||||||
|
github.com/enetx/surf v1.0.187
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/enetx/http v1.0.25 // indirect
|
||||||
|
github.com/enetx/http2 v1.0.25 // indirect
|
||||||
|
github.com/enetx/http3 v1.0.7 // indirect
|
||||||
|
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f // indirect
|
||||||
|
github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/wzshiming/socks5 v0.7.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
)
|
||||||
48
go.sum
Normal file
48
go.sum
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/enetx/g v1.0.210 h1:V5Z9iAUwgW5Ou1hOHG2Fl/0EkuUnGIthj0U4wLLTOA4=
|
||||||
|
github.com/enetx/g v1.0.210/go.mod h1:6/HQeRy+tIJVGY+oRPQVJ/vOSruAi0aldFggurT6jBY=
|
||||||
|
github.com/enetx/http v1.0.25 h1:WE1+KEnjXIHP+hxbnTmAZ0p87UEmiHaE4CAQDLzL5C4=
|
||||||
|
github.com/enetx/http v1.0.25/go.mod h1:1f4mytfF/SfjATEJnynpwGS6aa1ALjb8DtmYgFVblY0=
|
||||||
|
github.com/enetx/http2 v1.0.25 h1:PSZN0I7j6Rzo3+rA6UE08xZITitBFSS7miSTBxedKFo=
|
||||||
|
github.com/enetx/http2 v1.0.25/go.mod h1:t54ex5HIS8V1+2j6cvEOv6umlrHsbUPFKQ54nYB58Nk=
|
||||||
|
github.com/enetx/http3 v1.0.7 h1:daFhveKBtv8rRallCjaHErzzSHIrq07ovoSvVkvhcMM=
|
||||||
|
github.com/enetx/http3 v1.0.7/go.mod h1:sqpVGZ9F1/wCiW6sjBUS2errKAh3SUYn6VlWE7LL6KM=
|
||||||
|
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f h1:GUW+4AWfECIEJ9oAxgEAVGCpaozMCjRiUYnuR6Q0bCQ=
|
||||||
|
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f/go.mod h1:oMZN8hGLUpi7QBlMEUqailocNy0NFAO/7Lu+Nwh9HMM=
|
||||||
|
github.com/enetx/surf v1.0.187 h1:EQj9jj7RFXXYyicrNW3D77XAWyl8Y8vh4mPmHy8+L6I=
|
||||||
|
github.com/enetx/surf v1.0.187/go.mod h1:nn2yuyWDc7r1qcJ68C4wtebN8v+KJdH8DINFcjOWtZE=
|
||||||
|
github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8 h1:jN2LdG4CG7cXOMAkQVDencj67Gdt7FFwDP1UaPcYyV8=
|
||||||
|
github.com/enetx/utls v0.0.0-20260115181616-c525a7d559c8/go.mod h1:jsHaW4RX6DteSbAHT/pW7iJxv7YXLf2NTr3k9PsmXoc=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/wzshiming/socks5 v0.7.0 h1:euJ+U48WrvVngi+opC8vAnpZ5sK12y1C2hPvb1f48Rg=
|
||||||
|
github.com/wzshiming/socks5 v0.7.0/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||||
|
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
129
internal/cli/brave.go
Normal file
129
internal/cli/brave.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/enetx/g"
|
||||||
|
"github.com/enetx/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BraveResult represents a single news result from Brave Search API
|
||||||
|
type BraveResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PageAge string `json:"page_age"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BraveNewsResponse represents the Brave News Search API response
|
||||||
|
type BraveNewsResponse struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Age string `json:"age"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchConfig holds search parameters
|
||||||
|
type SearchConfig struct {
|
||||||
|
Query string
|
||||||
|
From time.Time
|
||||||
|
To time.Time
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchBrave queries the Brave News Search API
|
||||||
|
// Uses dedicated News endpoint: GET /res/v1/news/search
|
||||||
|
func SearchBrave(client *surf.Client, config SearchConfig) ([]BraveResult, error) {
|
||||||
|
apiKey := os.Getenv("BRAVE_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("BRAVE_API_KEY environment variable not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("q", config.Query)
|
||||||
|
params.Set("count", fmt.Sprintf("%d", config.Count))
|
||||||
|
params.Set("freshness", fmt.Sprintf("%sto%s",
|
||||||
|
config.From.Format("2006-01-02"),
|
||||||
|
config.To.Format("2006-01-02"),
|
||||||
|
))
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://api.search.brave.com/res/v1/news/search?%s", params.Encode())
|
||||||
|
|
||||||
|
// Use plain client for API calls (no Chrome impersonation which overrides headers)
|
||||||
|
apiClient := surf.NewClient()
|
||||||
|
defer apiClient.CloseIdleConnections()
|
||||||
|
|
||||||
|
resp := apiClient.Get(g.String(apiURL)).
|
||||||
|
SetHeaders("Accept", "application/json").
|
||||||
|
SetHeaders("X-Subscription-Token", apiKey).
|
||||||
|
// Jakarta, Indonesia location headers
|
||||||
|
SetHeaders("X-Loc-Lat", "-6.2088").
|
||||||
|
SetHeaders("X-Loc-Long", "106.8456").
|
||||||
|
SetHeaders("X-Loc-Timezone", "Asia/Jakarta").
|
||||||
|
SetHeaders("X-Loc-Country", "ID").
|
||||||
|
Do()
|
||||||
|
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("brave API request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
return nil, fmt.Errorf("brave API returned status %d: %s", r.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var braveResp BraveNewsResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &braveResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse brave API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(braveResp.Results) == 0 {
|
||||||
|
return []BraveResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]BraveResult, len(braveResp.Results))
|
||||||
|
for i, r := range braveResp.Results {
|
||||||
|
results[i] = BraveResult{
|
||||||
|
Title: r.Title,
|
||||||
|
URL: r.URL,
|
||||||
|
Description: r.Description,
|
||||||
|
PageAge: r.Age,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildStockQuery creates a boolean query for Indonesian stock news
|
||||||
|
// Example: BuildStockQuery("MINA", "MINA Tbk") returns:
|
||||||
|
// ("MINA" OR "MINA Tbk") AND (saham OR emiten OR "Bursa Efek Indonesia" OR BEI OR IDX)
|
||||||
|
func BuildStockQuery(stockTerms ...string) string {
|
||||||
|
if len(stockTerms) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build stock terms part
|
||||||
|
stockPart := "("
|
||||||
|
for i, term := range stockTerms {
|
||||||
|
if i > 0 {
|
||||||
|
stockPart += " OR "
|
||||||
|
}
|
||||||
|
stockPart += fmt.Sprintf(`"%s"`, term)
|
||||||
|
}
|
||||||
|
stockPart += ")"
|
||||||
|
|
||||||
|
// Indonesian stock market keywords
|
||||||
|
marketKeywords := `(saham OR emiten OR "Bursa Efek Indonesia" OR BEI OR IDX)`
|
||||||
|
|
||||||
|
return stockPart + " AND " + marketKeywords
|
||||||
|
}
|
||||||
282
internal/cli/extractor.go
Normal file
282
internal/cli/extractor.go
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractRequest is the request sent to Python extractor
|
||||||
|
type ExtractRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractResponse is the response from Python extractor
|
||||||
|
type ExtractResponse struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extractor manages the Python extraction process and socket communication
|
||||||
|
type Extractor struct {
|
||||||
|
socketPath string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
mu sync.Mutex
|
||||||
|
connPool chan net.Conn
|
||||||
|
poolSize int
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExtractor creates and starts the Python extractor process
|
||||||
|
func NewExtractor(poolSize int) (*Extractor, error) {
|
||||||
|
if poolSize < 1 {
|
||||||
|
poolSize = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("stock-news-extractor-%d-%d.sock", os.Getpid(), time.Now().UnixNano()))
|
||||||
|
|
||||||
|
e := &Extractor{
|
||||||
|
socketPath: socketPath,
|
||||||
|
poolSize: poolSize,
|
||||||
|
connPool: make(chan net.Conn, poolSize),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// start launches the Python extractor process
|
||||||
|
func (e *Extractor) start() error {
|
||||||
|
// Remove existing socket file if present
|
||||||
|
os.Remove(e.socketPath)
|
||||||
|
|
||||||
|
e.cmd = exec.Command("uv", "run", "python", "extractor.py", "--socket", e.socketPath)
|
||||||
|
e.cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
// Create a new process group so we can kill all child processes
|
||||||
|
e.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
stdout, err := e.cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start python extractor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for ready signal from Python
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
ready := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "READY:") {
|
||||||
|
ready <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ready <- false
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ok := <-ready:
|
||||||
|
if !ok {
|
||||||
|
e.killProcessGroup()
|
||||||
|
return fmt.Errorf("python extractor failed to start")
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
e.killProcessGroup()
|
||||||
|
return fmt.Errorf("timeout waiting for python extractor to start")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize connection pool
|
||||||
|
for i := 0; i < e.poolSize; i++ {
|
||||||
|
conn, err := net.Dial("unix", e.socketPath)
|
||||||
|
if err != nil {
|
||||||
|
e.Close()
|
||||||
|
return fmt.Errorf("failed to connect to extractor: %w", err)
|
||||||
|
}
|
||||||
|
e.connPool <- conn
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// killProcessGroup kills the entire process group
|
||||||
|
func (e *Extractor) killProcessGroup() {
|
||||||
|
if e.cmd != nil && e.cmd.Process != nil {
|
||||||
|
// Kill the entire process group (negative PID)
|
||||||
|
pgid, err := syscall.Getpgid(e.cmd.Process.Pid)
|
||||||
|
if err == nil {
|
||||||
|
syscall.Kill(-pgid, syscall.SIGTERM)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
syscall.Kill(-pgid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
e.cmd.Process.Kill()
|
||||||
|
e.cmd.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract sends HTML to Python and returns extracted text
|
||||||
|
func (e *Extractor) Extract(ctx context.Context, url, html string) (*ExtractResponse, error) {
|
||||||
|
// Get connection from pool
|
||||||
|
var conn net.Conn
|
||||||
|
select {
|
||||||
|
case conn = <-e.connPool:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
healthy := true
|
||||||
|
// Return connection to pool when done
|
||||||
|
defer func() {
|
||||||
|
if conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !e.closed && healthy {
|
||||||
|
e.connPool <- conn
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
if e.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replacement, err := net.Dial("unix", e.socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case e.connPool <- replacement:
|
||||||
|
default:
|
||||||
|
_ = replacement.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if dl, ok := ctx.Deadline(); ok {
|
||||||
|
_ = conn.SetDeadline(dl)
|
||||||
|
} else {
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
}
|
||||||
|
defer conn.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
req := ExtractRequest{URL: url, HTML: html}
|
||||||
|
if err := writeMessage(conn, req); err != nil {
|
||||||
|
healthy = false
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ExtractResponse
|
||||||
|
if err := readMessage(conn, &resp); err != nil {
|
||||||
|
healthy = false
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the Python extractor
|
||||||
|
func (e *Extractor) Close() error {
|
||||||
|
e.mu.Lock()
|
||||||
|
if e.closed {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
e.closed = true
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
// Drain and close all connections in pool
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < e.poolSize; i++ {
|
||||||
|
select {
|
||||||
|
case conn := <-e.connPool:
|
||||||
|
conn.Close()
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
// Timeout waiting for connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
// Timeout waiting for pool drain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill the process group
|
||||||
|
e.killProcessGroup()
|
||||||
|
|
||||||
|
// Clean up socket file
|
||||||
|
_ = os.Remove(e.socketPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMessage writes a length-prefixed JSON message
|
||||||
|
func writeMessage(conn net.Conn, msg interface{}) error {
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write 4-byte length prefix (big-endian)
|
||||||
|
header := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(header, uint32(len(data)))
|
||||||
|
|
||||||
|
if err := writeAll(conn, header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeAll(conn, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMessage reads a length-prefixed JSON message
|
||||||
|
func readMessage(conn net.Conn, v interface{}) error {
|
||||||
|
// Read 4-byte length prefix
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(conn, header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
length := binary.BigEndian.Uint32(header)
|
||||||
|
|
||||||
|
// Read JSON payload
|
||||||
|
data := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(conn, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(data, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAll(conn net.Conn, p []byte) error {
|
||||||
|
for len(p) > 0 {
|
||||||
|
n, err := conn.Write(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p = p[n:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
143
internal/cli/main.go
Normal file
143
internal/cli/main.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
printRootUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||||
|
printRootUsage()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "msn":
|
||||||
|
return runMSNCommand(args[1:])
|
||||||
|
case "news":
|
||||||
|
return runNewsCommand(args[1:])
|
||||||
|
case "export":
|
||||||
|
return runExportCommand(args[1:])
|
||||||
|
case "extractor":
|
||||||
|
return runExtractorCommand(args[1:])
|
||||||
|
default:
|
||||||
|
printRootUsage()
|
||||||
|
fmt.Fprintf(os.Stderr, "\nerror: unknown command: %s\n", args[0])
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRootUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Rubick - Unified Market Intelligence CLI
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
rubick <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
msn MSN finance workflows (screener, fetch, fetch-all, lookup)
|
||||||
|
news Brave news search + Python text extraction
|
||||||
|
export Python export tools (dashboard/history/simple)
|
||||||
|
extractor Run raw Python extractor server (advanced)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# News mode with explicit command
|
||||||
|
rubick news "BBCA,Bank Central Asia" --stock --count 20
|
||||||
|
|
||||||
|
# MSN screener
|
||||||
|
rubick msn screener --region id --filter top-performers --limit 20 -o output/screener.json
|
||||||
|
|
||||||
|
# MSN fetch-all to SQLite
|
||||||
|
rubick msn fetch-all --index idx30 --db output/stocks.db --concurrency 3
|
||||||
|
|
||||||
|
# Export dashboard from SQLite
|
||||||
|
rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
|
||||||
|
|
||||||
|
# Export history workbook from SQLite
|
||||||
|
rubick export history --db output/stocks.db --output output/history.xlsx
|
||||||
|
|
||||||
|
# Export simple tables (json/csv/xlsx) from SQLite
|
||||||
|
rubick export simple --db output/stocks.db --format csv --output output/simple_csv
|
||||||
|
|
||||||
|
# Run extractor server directly
|
||||||
|
rubick extractor --socket /tmp/extractor.sock
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExportCommand(args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: rubick export <dashboard|history|simple> [script options]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
|
||||||
|
rubick export history --db output/stocks.db --output output/history.xlsx
|
||||||
|
rubick export simple --db output/stocks.db --format csv --output output/csv/
|
||||||
|
`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: rubick export <dashboard|history|simple> [script options]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
rubick export dashboard --db output/stocks.db --output output/dashboard.xlsx
|
||||||
|
rubick export history --db output/stocks.db --output output/history.xlsx
|
||||||
|
rubick export simple --db output/stocks.db --format csv --output output/csv/
|
||||||
|
`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
script := ""
|
||||||
|
switch args[0] {
|
||||||
|
case "dashboard":
|
||||||
|
script = "scripts/export_dashboard.py"
|
||||||
|
case "history":
|
||||||
|
script = "scripts/export_history.py"
|
||||||
|
case "simple":
|
||||||
|
script = "scripts/export_simple.py"
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown export target: %s\n", args[0])
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdArgs := append([]string{"run", "python", script}, args[1:]...)
|
||||||
|
return runPassthrough("uv", cmdArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExtractorCommand(args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: rubick extractor --socket /tmp/extractor.sock\n")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: rubick extractor --socket /tmp/extractor.sock\n")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdArgs := append([]string{"run", "python", "extractor.py"}, args...)
|
||||||
|
return runPassthrough("uv", cmdArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPassthrough(bin string, args []string) int {
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "failed to run %s: %v\n", bin, err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
904
internal/cli/msn_cli.go
Normal file
904
internal/cli/msn_cli.go
Normal file
|
|
@ -0,0 +1,904 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"rubick/msn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runMSNCommand(args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
printMSNUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||||
|
printMSNUsage()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer signal.Stop(sigChan)
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
log.Println("Shutting down...")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := executeMSNCommand(ctx, args); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMSNCommand(ctx context.Context, args []string) error {
|
||||||
|
subcommand := args[0]
|
||||||
|
subArgs := args[1:]
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "screener":
|
||||||
|
return runScreener(ctx, subArgs)
|
||||||
|
case "fetch":
|
||||||
|
return runFetch(ctx, subArgs)
|
||||||
|
case "fetch-all":
|
||||||
|
return runFetchAll(ctx, subArgs)
|
||||||
|
case "lookup":
|
||||||
|
return runLookup(subArgs)
|
||||||
|
default:
|
||||||
|
printMSNUsage()
|
||||||
|
return fmt.Errorf("unknown msn subcommand: %s", subcommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMSNUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `MSN Stock Scraper - Fetch Indonesian stock data from MSN Finance
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
rubick msn <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
screener Run stock screener to find stocks by criteria
|
||||||
|
fetch Fetch comprehensive data for specific stocks
|
||||||
|
fetch-all Fetch ALL Indonesian stocks to SQLite database
|
||||||
|
lookup Look up MSN ID for ticker symbols
|
||||||
|
|
||||||
|
Screener:
|
||||||
|
rubick msn screener --region id --filter top-performers --limit 20 -o stocks.json
|
||||||
|
|
||||||
|
Fetch:
|
||||||
|
rubick msn fetch --tickers BBCA,BBRI,TLKM -o bank_stocks.json
|
||||||
|
rubick msn fetch --input stocks.json -o full_data.json
|
||||||
|
|
||||||
|
Fetch-All:
|
||||||
|
rubick msn fetch-all --index idx30 --db output/stocks.db --concurrency 3
|
||||||
|
|
||||||
|
Lookup:
|
||||||
|
rubick msn lookup BBCA BBRI TLKM
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screener
|
||||||
|
|
||||||
|
type ScreenerCLIConfig struct {
|
||||||
|
Region string
|
||||||
|
Filter string
|
||||||
|
Limit int
|
||||||
|
Output string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScreenerArgs(args []string) (ScreenerCLIConfig, error) {
|
||||||
|
cfg := ScreenerCLIConfig{
|
||||||
|
Region: "id",
|
||||||
|
Filter: "large-cap",
|
||||||
|
Limit: 50,
|
||||||
|
Output: fmt.Sprintf("screener_%s.json", time.Now().Format("20060102")),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--region":
|
||||||
|
v, n, err := requireValue(args, i, "--region")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Region = v
|
||||||
|
i = n
|
||||||
|
case "--filter":
|
||||||
|
v, n, err := requireValue(args, i, "--filter")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Filter = v
|
||||||
|
i = n
|
||||||
|
case "--limit":
|
||||||
|
v, n, err := requireValue(args, i, "--limit")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
limit, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --limit value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Limit = limit
|
||||||
|
i = n
|
||||||
|
case "--output", "-o":
|
||||||
|
v, n, err := requireValue(args, i, "--output")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Output = v
|
||||||
|
i = n
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("unknown option: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Limit < 1 {
|
||||||
|
return cfg, fmt.Errorf("--limit must be >= 1")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runScreener(ctx context.Context, args []string) error {
|
||||||
|
if wantsHelp(args) {
|
||||||
|
printMSNScreenerUsage()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := parseScreenerArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filter, err := msn.ParseScreenerFilter(cfg.Filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid filter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Running screener: region=%s filter=%s limit=%d", cfg.Region, cfg.Filter, cfg.Limit)
|
||||||
|
client := msn.NewMSNClient()
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
result, err := client.RunScreener(msn.ScreenerConfig{Region: cfg.Region, Filter: filter, Limit: cfg.Limit})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("screener failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := msn.ScreenerOutput{
|
||||||
|
Filter: cfg.Filter,
|
||||||
|
Region: cfg.Region,
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Total: result.Total,
|
||||||
|
Stocks: result.Value,
|
||||||
|
}
|
||||||
|
if err := saveJSON(output, cfg.Output); err != nil {
|
||||||
|
return fmt.Errorf("failed to save output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Output saved to %s", cfg.Output)
|
||||||
|
fmt.Println("\nTop 10 results:")
|
||||||
|
for i, stock := range result.Value {
|
||||||
|
if i >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s (%s): %.2f (%.2f%%)\n", stock.Symbol, stock.ID, stock.Price, stock.PriceChangePct)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch
|
||||||
|
|
||||||
|
type FetchCLIConfig struct {
|
||||||
|
Input string
|
||||||
|
IDs []string
|
||||||
|
Tickers []string
|
||||||
|
Concurrency int
|
||||||
|
Output string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFetchArgs(args []string) (FetchCLIConfig, error) {
|
||||||
|
cfg := FetchCLIConfig{Concurrency: 5, Output: fmt.Sprintf("stocks_%s.json", time.Now().Format("20060102"))}
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--input":
|
||||||
|
v, n, err := requireValue(args, i, "--input")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Input = v
|
||||||
|
i = n
|
||||||
|
case "--ids":
|
||||||
|
v, n, err := requireValue(args, i, "--ids")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.IDs = appendCSV(cfg.IDs, v, false)
|
||||||
|
i = n
|
||||||
|
case "--tickers":
|
||||||
|
v, n, err := requireValue(args, i, "--tickers")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Tickers = appendCSV(cfg.Tickers, v, true)
|
||||||
|
i = n
|
||||||
|
case "--concurrency":
|
||||||
|
v, n, err := requireValue(args, i, "--concurrency")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
conc, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --concurrency value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Concurrency = conc
|
||||||
|
i = n
|
||||||
|
case "--output", "-o":
|
||||||
|
v, n, err := requireValue(args, i, "--output")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Output = v
|
||||||
|
i = n
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("unknown option: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Concurrency < 1 {
|
||||||
|
return cfg, fmt.Errorf("--concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFetch(ctx context.Context, args []string) error {
|
||||||
|
if wantsHelp(args) {
|
||||||
|
printMSNFetchUsage()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := parseFetchArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, 0)
|
||||||
|
if cfg.Input != "" {
|
||||||
|
inputIDs, err := readScreenerOutput(cfg.Input)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read input file: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, inputIDs...)
|
||||||
|
}
|
||||||
|
ids = append(ids, cfg.IDs...)
|
||||||
|
|
||||||
|
for _, ticker := range cfg.Tickers {
|
||||||
|
id := msn.GetIDXStockID(ticker)
|
||||||
|
if id == "" {
|
||||||
|
log.Printf("Warning: Unknown ticker '%s', skipping", ticker)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Resolved %s -> %s", ticker, id)
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return fmt.Errorf("no stock IDs provided. use --input, --ids, or --tickers")
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = dedupe(ids)
|
||||||
|
log.Printf("Fetching data for %d stocks with concurrency %d", len(ids), cfg.Concurrency)
|
||||||
|
|
||||||
|
fetcher := msn.NewStockFetcher()
|
||||||
|
defer fetcher.Close()
|
||||||
|
|
||||||
|
stocks := fetcher.FetchStocks(ctx, ids, cfg.Concurrency)
|
||||||
|
output := msn.FetchOutput{GeneratedAt: time.Now().UTC().Format(time.RFC3339), Total: len(stocks), Stocks: stocks}
|
||||||
|
if err := saveJSON(output, cfg.Output); err != nil {
|
||||||
|
return fmt.Errorf("failed to save output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
for _, stock := range stocks {
|
||||||
|
apiSuccess := 0
|
||||||
|
for _, status := range stock.FetchStatus {
|
||||||
|
if status == "ok" {
|
||||||
|
apiSuccess++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if apiSuccess > 0 {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Output saved to %s", cfg.Output)
|
||||||
|
log.Printf("Successfully fetched %d/%d stocks", successCount, len(stocks))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readScreenerOutput(filename string) ([]string, error) {
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var output msn.ScreenerOutput
|
||||||
|
if err := json.Unmarshal(data, &output); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, len(output.Stocks))
|
||||||
|
for i, stock := range output.Stocks {
|
||||||
|
ids[i] = stock.ID
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveJSON(data any, filename string) error {
|
||||||
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(filename); dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.WriteFile(filename, jsonData, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLookup(args []string) error {
|
||||||
|
if wantsHelp(args) {
|
||||||
|
printMSNLookupUsage()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("usage: rubick msn lookup <ticker1> [ticker2] ...")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-8s %-10s %s\n", "Ticker", "MSN ID", "Company Name")
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
|
||||||
|
found := 0
|
||||||
|
for _, ticker := range args {
|
||||||
|
ticker = strings.ToUpper(strings.TrimSpace(ticker))
|
||||||
|
stock, ok := msn.GetIDXStock(ticker)
|
||||||
|
if ok {
|
||||||
|
fmt.Printf("%-8s %-10s %s\n", ticker, stock.ID, stock.Name)
|
||||||
|
found++
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%-8s %-10s %s\n", ticker, "-", "(not found)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(strings.Repeat("-", 50))
|
||||||
|
fmt.Printf("Found %d/%d tickers\n", found, len(args))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch-all
|
||||||
|
|
||||||
|
type FetchAllConfig struct {
|
||||||
|
DB string
|
||||||
|
Index string
|
||||||
|
Proxy string
|
||||||
|
Concurrency int
|
||||||
|
RPS float64
|
||||||
|
MinDelayMs int
|
||||||
|
MaxDelayMs int
|
||||||
|
Retry int
|
||||||
|
Limit int
|
||||||
|
Resume bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFetchAllArgs(args []string) (FetchAllConfig, error) {
|
||||||
|
cfg := FetchAllConfig{
|
||||||
|
DB: "output/stocks.db",
|
||||||
|
Index: "all",
|
||||||
|
Concurrency: 5,
|
||||||
|
RPS: 25,
|
||||||
|
MinDelayMs: 100,
|
||||||
|
MaxDelayMs: 500,
|
||||||
|
Retry: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--db":
|
||||||
|
v, n, err := requireValue(args, i, "--db")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.DB = v
|
||||||
|
i = n
|
||||||
|
case "--index":
|
||||||
|
v, n, err := requireValue(args, i, "--index")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Index = strings.ToLower(v)
|
||||||
|
i = n
|
||||||
|
case "--proxy":
|
||||||
|
v, n, err := requireValue(args, i, "--proxy")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Proxy = v
|
||||||
|
i = n
|
||||||
|
case "--concurrency":
|
||||||
|
v, n, err := requireValue(args, i, "--concurrency")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
nval, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --concurrency value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Concurrency = nval
|
||||||
|
i = n
|
||||||
|
case "--rps":
|
||||||
|
v, n, err := requireValue(args, i, "--rps")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
fval, err := strconv.ParseFloat(v, 64)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --rps value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.RPS = fval
|
||||||
|
i = n
|
||||||
|
case "--delay":
|
||||||
|
v, n, err := requireValue(args, i, "--delay")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
minDelay, maxDelay, err := parseDelay(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.MinDelayMs, cfg.MaxDelayMs = minDelay, maxDelay
|
||||||
|
i = n
|
||||||
|
case "--retry":
|
||||||
|
v, n, err := requireValue(args, i, "--retry")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
nval, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --retry value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Retry = nval
|
||||||
|
i = n
|
||||||
|
case "--limit":
|
||||||
|
v, n, err := requireValue(args, i, "--limit")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
nval, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --limit value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Limit = nval
|
||||||
|
i = n
|
||||||
|
case "--resume":
|
||||||
|
cfg.Resume = true
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("unknown option: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Concurrency < 1 {
|
||||||
|
return cfg, fmt.Errorf("--concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
if cfg.RPS <= 0 {
|
||||||
|
return cfg, fmt.Errorf("--rps must be > 0")
|
||||||
|
}
|
||||||
|
if cfg.MinDelayMs < 0 || cfg.MaxDelayMs < 0 || cfg.MinDelayMs > cfg.MaxDelayMs {
|
||||||
|
return cfg, fmt.Errorf("--delay must satisfy 0 <= min <= max")
|
||||||
|
}
|
||||||
|
if cfg.Retry < 0 {
|
||||||
|
return cfg, fmt.Errorf("--retry must be >= 0")
|
||||||
|
}
|
||||||
|
if cfg.Limit < 0 {
|
||||||
|
return cfg, fmt.Errorf("--limit must be >= 0")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFetchAll(ctx context.Context, args []string) error {
|
||||||
|
if wantsHelp(args) {
|
||||||
|
printMSNFetchAllUsage()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := parseFetchAllArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Fetch-All Configuration:")
|
||||||
|
log.Printf(" Database: %s", cfg.DB)
|
||||||
|
log.Printf(" Index: %s", cfg.Index)
|
||||||
|
log.Printf(" Concurrency: %d workers", cfg.Concurrency)
|
||||||
|
log.Printf(" Rate limit: %.1f req/sec", cfg.RPS)
|
||||||
|
log.Printf(" Delay: %d-%d ms", cfg.MinDelayMs, cfg.MaxDelayMs)
|
||||||
|
log.Printf(" Retry: %d attempts", cfg.Retry)
|
||||||
|
if cfg.Proxy != "" {
|
||||||
|
log.Printf(" Proxy: %s", cfg.Proxy)
|
||||||
|
}
|
||||||
|
if cfg.Limit > 0 {
|
||||||
|
log.Printf(" Limit: %d stocks", cfg.Limit)
|
||||||
|
}
|
||||||
|
if cfg.Resume {
|
||||||
|
log.Printf(" Resume: enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := msn.NewStockDB(cfg.DB)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
stocks := getStocksByIndex(cfg.Index)
|
||||||
|
if cfg.Limit > 0 && cfg.Limit < len(stocks) {
|
||||||
|
limited := make(map[string]msn.IDXStock)
|
||||||
|
count := 0
|
||||||
|
for ticker, stock := range stocks {
|
||||||
|
if count >= cfg.Limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
limited[ticker] = stock
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
stocks = limited
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Stock list: %d stocks from '%s' index", len(stocks), cfg.Index)
|
||||||
|
|
||||||
|
var runID int64
|
||||||
|
if cfg.Resume {
|
||||||
|
runID, err = db.GetLastRunID()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last run: %w", err)
|
||||||
|
}
|
||||||
|
if runID > 0 {
|
||||||
|
log.Printf("Resuming run #%d", runID)
|
||||||
|
} else {
|
||||||
|
log.Printf("No incomplete run found, starting fresh")
|
||||||
|
cfg.Resume = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.Resume {
|
||||||
|
cfgMap := map[string]any{
|
||||||
|
"index": cfg.Index,
|
||||||
|
"concurrency": cfg.Concurrency,
|
||||||
|
"rps": cfg.RPS,
|
||||||
|
"delay": fmt.Sprintf("%d-%d", cfg.MinDelayMs, cfg.MaxDelayMs),
|
||||||
|
"retry": cfg.Retry,
|
||||||
|
"proxy": cfg.Proxy != "",
|
||||||
|
}
|
||||||
|
runID, err = db.StartScrapeRun(len(stocks), cfgMap)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start run: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Started run #%d", runID)
|
||||||
|
if err := db.InitProgress(runID, stocks); err != nil {
|
||||||
|
return fmt.Errorf("failed to init progress: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingStocks, err := db.GetPendingStocks(runID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get pending stocks: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Pending: %d stocks to process", len(pendingStocks))
|
||||||
|
if len(pendingStocks) == 0 {
|
||||||
|
log.Println("No pending stocks, run complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter := msn.NewRateLimiter(msn.RateLimiterConfig{RequestsPerSecond: cfg.RPS, MinDelayMs: cfg.MinDelayMs, MaxDelayMs: cfg.MaxDelayMs})
|
||||||
|
client := msn.NewMSNClientWithConfig(msn.MSNClientConfig{Proxy: cfg.Proxy, RateLimiter: rateLimiter})
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
type workItem struct{ ID, Ticker string }
|
||||||
|
workChan := make(chan workItem, len(pendingStocks))
|
||||||
|
for _, s := range pendingStocks {
|
||||||
|
workChan <- workItem{ID: s.ID, Ticker: s.Ticker}
|
||||||
|
}
|
||||||
|
close(workChan)
|
||||||
|
|
||||||
|
var processed, successful, failed int
|
||||||
|
total := len(pendingStocks)
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
statusTicker := time.NewTicker(5 * time.Second)
|
||||||
|
defer statusTicker.Stop()
|
||||||
|
go func() {
|
||||||
|
for range statusTicker.C {
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
rate := float64(processed) / elapsed.Seconds()
|
||||||
|
remaining := total - processed
|
||||||
|
eta := time.Duration(float64(remaining)/rate) * time.Second
|
||||||
|
log.Printf("Progress: %d/%d (%.1f%%) | Success: %d | Failed: %d | Rate: %.1f/s | ETA: %s",
|
||||||
|
processed, total, float64(processed)*100/float64(total), successful, failed, rate, eta.Round(time.Second))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
results := make(chan struct {
|
||||||
|
ticker string
|
||||||
|
success bool
|
||||||
|
apis int
|
||||||
|
err string
|
||||||
|
}, cfg.Concurrency)
|
||||||
|
|
||||||
|
for w := 0; w < cfg.Concurrency; w++ {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case work, ok := <-workChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.MarkProgressStarted(runID, work.ID)
|
||||||
|
var stockData *msn.StockData
|
||||||
|
var fetchErr error
|
||||||
|
for attempt := 0; attempt <= cfg.Retry; attempt++ {
|
||||||
|
stockData, fetchErr = client.FetchStockData(work.ID)
|
||||||
|
if fetchErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if attempt < cfg.Retry {
|
||||||
|
time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apisSuccess, apisFailed := 0, 0
|
||||||
|
if stockData != nil {
|
||||||
|
for _, status := range stockData.FetchStatus {
|
||||||
|
if status == "ok" {
|
||||||
|
apisSuccess++
|
||||||
|
} else {
|
||||||
|
apisFailed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.SaveStockData(stockData); err != nil {
|
||||||
|
fetchErr = fmt.Errorf("save failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := "success"
|
||||||
|
errMsg := ""
|
||||||
|
if fetchErr != nil || apisSuccess == 0 {
|
||||||
|
status = "failed"
|
||||||
|
if fetchErr != nil {
|
||||||
|
errMsg = fetchErr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.UpdateProgress(runID, work.ID, status, apisSuccess, apisFailed, errMsg)
|
||||||
|
results <- struct {
|
||||||
|
ticker string
|
||||||
|
success bool
|
||||||
|
apis int
|
||||||
|
err string
|
||||||
|
}{work.Ticker, status == "success", apisSuccess, errMsg}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for processed < total {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
done <- false
|
||||||
|
return
|
||||||
|
case r := <-results:
|
||||||
|
processed++
|
||||||
|
if r.success {
|
||||||
|
successful++
|
||||||
|
log.Printf("[%d/%d] %s - %d APIs succeeded", processed, total, r.ticker, r.apis)
|
||||||
|
} else {
|
||||||
|
failed++
|
||||||
|
log.Printf("[%d/%d] %s - FAILED: %s", processed, total, r.ticker, r.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
completed := <-done
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
if completed {
|
||||||
|
db.CompleteScrapeRun(runID, "completed")
|
||||||
|
log.Printf("\n=== Run #%d Completed ===", runID)
|
||||||
|
} else {
|
||||||
|
db.CompleteScrapeRun(runID, "interrupted")
|
||||||
|
log.Printf("\n=== Run #%d Interrupted ===", runID)
|
||||||
|
}
|
||||||
|
log.Printf("Total: %d | Success: %d | Failed: %d", processed, successful, failed)
|
||||||
|
log.Printf("Duration: %s | Rate: %.1f stocks/sec", elapsed.Round(time.Second), float64(processed)/elapsed.Seconds())
|
||||||
|
log.Printf("Database: %s", cfg.DB)
|
||||||
|
|
||||||
|
staleStocks, _ := db.GetStaleStocks(7)
|
||||||
|
if len(staleStocks) > 0 {
|
||||||
|
log.Printf("\nWarning: %d stocks not seen in 7+ days:", len(staleStocks))
|
||||||
|
for i, ticker := range staleStocks {
|
||||||
|
if i >= 10 {
|
||||||
|
log.Printf(" ... and %d more", len(staleStocks)-10)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf(" - %s", ticker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
func requireValue(args []string, i int, flag string) (string, int, error) {
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return "", i, fmt.Errorf("%s requires a value", flag)
|
||||||
|
}
|
||||||
|
return args[i+1], i + 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCSV(dst []string, csv string, upper bool) []string {
|
||||||
|
for _, part := range strings.Split(csv, ",") {
|
||||||
|
v := strings.TrimSpace(part)
|
||||||
|
if upper {
|
||||||
|
v = strings.ToUpper(v)
|
||||||
|
}
|
||||||
|
if v != "" {
|
||||||
|
dst = append(dst, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupe(values []string) []string {
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
if !seen[v] {
|
||||||
|
seen[v] = true
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDelay(v string) (int, int, error) {
|
||||||
|
parts := strings.Split(v, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("invalid --delay value (use format min-max)")
|
||||||
|
}
|
||||||
|
minDelay, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid --delay min value: %w", err)
|
||||||
|
}
|
||||||
|
maxDelay, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid --delay max value: %w", err)
|
||||||
|
}
|
||||||
|
if minDelay > maxDelay {
|
||||||
|
return 0, 0, fmt.Errorf("invalid --delay value: min must be <= max")
|
||||||
|
}
|
||||||
|
return minDelay, maxDelay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantsHelp(args []string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
if a == "-h" || a == "--help" || a == "help" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMSNScreenerUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: rubick msn screener [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--region <code> Country code (default: id)
|
||||||
|
--filter <preset> top-performers|worst-performers|high-dividend|low-pe|52w-high|52w-low|high-volume|large-cap
|
||||||
|
--limit <n> Max results (default: 50)
|
||||||
|
--output, -o <file> Output JSON path
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMSNFetchUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: rubick msn fetch [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--input <file> Screener JSON input
|
||||||
|
--ids <id1,id2,...> Comma-separated MSN IDs
|
||||||
|
--tickers <T1,T2,...> Comma-separated ticker symbols
|
||||||
|
--concurrency <n> Parallel workers (default: 5)
|
||||||
|
--output, -o <file> Output JSON path
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMSNFetchAllUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: rubick msn fetch-all [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--db <file> SQLite database path (default: output/stocks.db)
|
||||||
|
--index <name> all|lq45|idx30|idx80 (default: all)
|
||||||
|
--proxy <url> Proxy URL (http://, https://, socks5://)
|
||||||
|
--concurrency <n> Parallel workers (default: 5)
|
||||||
|
--rps <n> Max requests/sec (default: 25)
|
||||||
|
--delay <min-max> Random delay ms (default: 100-500)
|
||||||
|
--retry <n> Retry attempts (default: 2)
|
||||||
|
--limit <n> Process only N stocks
|
||||||
|
--resume Resume incomplete run
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMSNLookupUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: rubick msn lookup <ticker1> [ticker2] ...
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStocksByIndex(index string) map[string]msn.IDXStock {
|
||||||
|
allStocks := msn.GetAllIDXStocks()
|
||||||
|
switch index {
|
||||||
|
case "all":
|
||||||
|
return allStocks
|
||||||
|
case "lq45":
|
||||||
|
return filterStocks(allStocks, []string{
|
||||||
|
"ACES", "ADRO", "AKRA", "AMMN", "AMRT", "ANTM", "ASII", "BBCA",
|
||||||
|
"BBNI", "BBRI", "BBTN", "BMRI", "BRPT", "BUKA", "CPIN", "EMTK",
|
||||||
|
"ESSA", "EXCL", "GGRM", "GOTO", "HRUM", "ICBP", "INCO", "INDF",
|
||||||
|
"INKP", "INTP", "ITMG", "KLBF", "MAPI", "MBMA", "MDKA", "MEDC",
|
||||||
|
"PGAS", "PGEO", "PTBA", "SIDO", "SMGR", "TBIG", "TINS", "TLKM",
|
||||||
|
"TOWR", "UNTR", "UNVR", "WIKA",
|
||||||
|
})
|
||||||
|
case "idx30":
|
||||||
|
return filterStocks(allStocks, []string{
|
||||||
|
"ADRO", "AMRT", "ANTM", "ASII", "BBCA", "BBNI", "BBRI", "BMRI",
|
||||||
|
"BRPT", "CPIN", "EMTK", "EXCL", "GOTO", "ICBP", "INCO", "INDF",
|
||||||
|
"ITMG", "KLBF", "MDKA", "MEDC", "PGAS", "PTBA", "SMGR", "TBIG",
|
||||||
|
"TINS", "TLKM", "TOWR", "UNTR", "UNVR",
|
||||||
|
})
|
||||||
|
case "idx80":
|
||||||
|
return filterStocks(allStocks, []string{
|
||||||
|
"ACES", "ADRO", "AGII", "AKRA", "AMMN", "AMRT", "ANTM", "ARTO",
|
||||||
|
"ASII", "BBCA", "BBNI", "BBRI", "BBTN", "BFIN", "BMRI", "BRPT",
|
||||||
|
"BSDE", "BTPS", "BUKA", "CPIN", "CTRA", "DMAS", "EMTK", "ERAA",
|
||||||
|
"ESSA", "EXCL", "GGRM", "GOTO", "HEAL", "HMSP", "HRUM", "ICBP",
|
||||||
|
"INCO", "INDF", "INKP", "INTP", "ITMG", "JPFA", "JSMR", "KLBF",
|
||||||
|
"LPKR", "LPPF", "MAPI", "MBMA", "MDKA", "MEDC", "MIKA", "MNCN",
|
||||||
|
"PGAS", "PGEO", "PNBN", "PTBA", "PTPP", "PWON", "SCMA", "SIDO",
|
||||||
|
"SMGR", "SMRA", "SRTG", "TAPG", "TBIG", "TINS", "TKIM", "TLKM",
|
||||||
|
"TOWR", "TPIA", "UNTR", "UNVR", "WIKA", "WSKT",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
log.Printf("Unknown index '%s', using all stocks", index)
|
||||||
|
return allStocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterStocks(all map[string]msn.IDXStock, tickers []string) map[string]msn.IDXStock {
|
||||||
|
result := make(map[string]msn.IDXStock)
|
||||||
|
for _, ticker := range tickers {
|
||||||
|
if stock, ok := all[ticker]; ok {
|
||||||
|
result[ticker] = stock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
336
internal/cli/news_cli.go
Normal file
336
internal/cli/news_cli.go
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/enetx/g"
|
||||||
|
"github.com/enetx/surf"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NewsConfig struct {
|
||||||
|
Query string
|
||||||
|
From time.Time
|
||||||
|
To time.Time
|
||||||
|
Count int
|
||||||
|
Concurrency int
|
||||||
|
Output string
|
||||||
|
StockMode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnrichedResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PageAge string `json:"page_age"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
FetchStatus string `json:"fetch_status"`
|
||||||
|
ExtractStatus string `json:"extract_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputData struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Results []EnrichedResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNewsCommand(args []string) int {
|
||||||
|
godotenv.Load()
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
printNewsUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||||
|
printNewsUsage()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := parseNewsArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeNews(cfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeNews(config NewsConfig) error {
|
||||||
|
finalQuery := config.Query
|
||||||
|
if config.StockMode {
|
||||||
|
terms := strings.Split(config.Query, ",")
|
||||||
|
for i := range terms {
|
||||||
|
terms[i] = strings.TrimSpace(terms[i])
|
||||||
|
}
|
||||||
|
finalQuery = BuildStockQuery(terms...)
|
||||||
|
log.Printf("Stock mode query: %s", finalQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer signal.Stop(sigChan)
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
log.Println("Shutting down...")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := surf.NewClient().Builder().Impersonate().Chrome().Build().Unwrap()
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
|
||||||
|
log.Printf("Searching for: %s", finalQuery)
|
||||||
|
log.Printf("Date range: %s to %s, count: %d", config.From.Format("2006-01-02"), config.To.Format("2006-01-02"), config.Count)
|
||||||
|
|
||||||
|
searchResults, err := SearchBrave(client, SearchConfig{Query: finalQuery, From: config.From, To: config.To, Count: config.Count})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to search: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Found %d results", len(searchResults))
|
||||||
|
if len(searchResults) == 0 {
|
||||||
|
log.Println("No results found, exiting")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Starting Python extractor...")
|
||||||
|
extractor, err := NewExtractor(config.Concurrency)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start extractor: %w", err)
|
||||||
|
}
|
||||||
|
defer extractor.Close()
|
||||||
|
log.Println("Python extractor ready")
|
||||||
|
|
||||||
|
results := processURLs(ctx, client, extractor, searchResults, config.Concurrency)
|
||||||
|
output := OutputData{Query: finalQuery, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Results: results}
|
||||||
|
if err := saveOutput(output, config.Output); err != nil {
|
||||||
|
return fmt.Errorf("failed to save output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if r.ExtractStatus == "ok" {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Output saved to %s", config.Output)
|
||||||
|
log.Printf("Successfully extracted %d/%d articles", successCount, len(results))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printNewsUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: rubick news <query> [options]
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
<query> Search query (required)
|
||||||
|
For --stock mode: comma-separated stock terms
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--from <date> Start date in YYYY-MM-DD format (default: 7 days ago)
|
||||||
|
--to <date> End date in YYYY-MM-DD format (default: today)
|
||||||
|
--count <n> Number of results to fetch (default: 20)
|
||||||
|
--concurrency <n> Number of parallel workers (default: 10)
|
||||||
|
--output, -o <file> Output file path (default: output_YYYYMMDD.json)
|
||||||
|
--stock Auto-builds IDX-focused boolean query
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
BRAVE_API_KEY Brave Search API key
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
rubick news "IHSG stock market"
|
||||||
|
rubick news "BBCA,Bank Central Asia" --stock --from 2026-02-01 --to 2026-02-10
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNewsArgs(args []string) (NewsConfig, error) {
|
||||||
|
query := args[0]
|
||||||
|
args = args[1:]
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cfg := NewsConfig{
|
||||||
|
Query: query,
|
||||||
|
From: now.AddDate(0, 0, -7),
|
||||||
|
To: now,
|
||||||
|
Count: 20,
|
||||||
|
Concurrency: 10,
|
||||||
|
Output: fmt.Sprintf("output_%s.json", now.Format("20060102")),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--from":
|
||||||
|
v, n, err := requireValue(args, i, "--from")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --from date: %w", err)
|
||||||
|
}
|
||||||
|
cfg.From = t
|
||||||
|
i = n
|
||||||
|
case "--to":
|
||||||
|
v, n, err := requireValue(args, i, "--to")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --to date: %w", err)
|
||||||
|
}
|
||||||
|
cfg.To = t
|
||||||
|
i = n
|
||||||
|
case "--count":
|
||||||
|
v, n, err := requireValue(args, i, "--count")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
nval, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --count value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Count = nval
|
||||||
|
i = n
|
||||||
|
case "--concurrency":
|
||||||
|
v, n, err := requireValue(args, i, "--concurrency")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
nval, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return cfg, fmt.Errorf("invalid --concurrency value: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Concurrency = nval
|
||||||
|
i = n
|
||||||
|
case "--output", "-o":
|
||||||
|
v, n, err := requireValue(args, i, "--output")
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
cfg.Output = v
|
||||||
|
i = n
|
||||||
|
case "--stock":
|
||||||
|
cfg.StockMode = true
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("unknown option: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Count < 1 {
|
||||||
|
return cfg, fmt.Errorf("--count must be >= 1")
|
||||||
|
}
|
||||||
|
if cfg.Concurrency < 1 {
|
||||||
|
return cfg, fmt.Errorf("--concurrency must be >= 1")
|
||||||
|
}
|
||||||
|
if cfg.From.After(cfg.To) {
|
||||||
|
return cfg, fmt.Errorf("--from must be on or before --to")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processURLs(ctx context.Context, client *surf.Client, extractor *Extractor, searchResults []BraveResult, concurrency int) []EnrichedResult {
|
||||||
|
results := make([]EnrichedResult, len(searchResults))
|
||||||
|
for i, sr := range searchResults {
|
||||||
|
results[i] = EnrichedResult{Title: sr.Title, URL: sr.URL, Description: sr.Description, PageAge: sr.PageAge, FetchStatus: "pending", ExtractStatus: "pending"}
|
||||||
|
}
|
||||||
|
|
||||||
|
work := make(chan int, len(searchResults))
|
||||||
|
for i := range searchResults {
|
||||||
|
work <- i
|
||||||
|
}
|
||||||
|
close(work)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
for w := 0; w < concurrency; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case idx, ok := <-work:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := processURL(ctx, client, extractor, searchResults[idx])
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
log.Printf("[%d/%d] %s - fetch: %s, extract: %s", idx+1, len(searchResults), truncate(result.URL, 50), result.FetchStatus, result.ExtractStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func processURL(ctx context.Context, client *surf.Client, extractor *Extractor, sr BraveResult) EnrichedResult {
|
||||||
|
result := EnrichedResult{Title: sr.Title, URL: sr.URL, Description: sr.Description, PageAge: sr.PageAge}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
result.FetchStatus = "cancelled"
|
||||||
|
result.ExtractStatus = "skipped"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
resp := client.Get(g.String(sr.URL)).Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
result.FetchStatus = "failed"
|
||||||
|
result.ExtractStatus = "skipped"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
result.FetchStatus = "failed"
|
||||||
|
result.ExtractStatus = "skipped"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
html := r.Body.String().Ok().Std()
|
||||||
|
result.FetchStatus = "ok"
|
||||||
|
extractResp, err := extractor.Extract(ctx, sr.URL, html)
|
||||||
|
if err != nil {
|
||||||
|
result.ExtractStatus = "failed"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Text = extractResp.Text
|
||||||
|
result.ExtractStatus = extractResp.Status
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveOutput(output OutputData, filename string) error {
|
||||||
|
data, err := json.MarshalIndent(output, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(filename); dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.WriteFile(filename, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
11
main.go
Normal file
11
main.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"rubick/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(cli.Run(os.Args[1:]))
|
||||||
|
}
|
||||||
324
msn/bing_client.go
Normal file
324
msn/bing_client.go
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
package msn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/enetx/g"
|
||||||
|
"github.com/enetx/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BingClient is the client for Bing Finance APIs (ownership data)
|
||||||
|
type BingClient struct {
|
||||||
|
client *surf.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBingClient creates a new Bing API client with Chrome impersonation
|
||||||
|
func NewBingClient() *BingClient {
|
||||||
|
client := surf.NewClient().
|
||||||
|
Builder().
|
||||||
|
Impersonate().
|
||||||
|
Chrome().
|
||||||
|
Build().
|
||||||
|
Unwrap()
|
||||||
|
|
||||||
|
return &BingClient{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes idle connections
|
||||||
|
func (c *BingClient) Close() {
|
||||||
|
c.client.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonHeaders returns common headers for Bing API requests
|
||||||
|
func (c *BingClient) commonHeaders() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Origin": "https://www.msn.com",
|
||||||
|
"Referer": "https://www.msn.com/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTopShareHolders fetches top institutional shareholders
|
||||||
|
func (c *BingClient) GetTopShareHolders(id string, count int) ([]Holder, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("no stock ID provided")
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
count = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sGetSecurityTopShareHolders/%s?rangeStart=1&count=%d",
|
||||||
|
BingAPIBaseURL,
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := c.client.Get(g.String(apiURL))
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req = req.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := req.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("top shareholders request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("top shareholders API returned status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var result OwnershipResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse top shareholders response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return whichever field has data
|
||||||
|
if len(result.SecurityOwnerships) > 0 {
|
||||||
|
return result.SecurityOwnerships, nil
|
||||||
|
}
|
||||||
|
return result.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTopBuyers fetches recent top buyers
|
||||||
|
func (c *BingClient) GetTopBuyers(id string, count int) ([]Holder, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("no stock ID provided")
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
count = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sGetSecurityTopBuyers/%s?rangeStart=1&count=%d",
|
||||||
|
BingAPIBaseURL,
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := c.client.Get(g.String(apiURL))
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req = req.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := req.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("top buyers request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("top buyers API returned status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var result OwnershipResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse top buyers response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.SecurityOwnerships) > 0 {
|
||||||
|
return result.SecurityOwnerships, nil
|
||||||
|
}
|
||||||
|
return result.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTopSellers fetches recent top sellers
|
||||||
|
func (c *BingClient) GetTopSellers(id string, count int) ([]Holder, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("no stock ID provided")
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
count = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sGetSecurityTopSellers/%s?rangeStart=1&count=%d",
|
||||||
|
BingAPIBaseURL,
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := c.client.Get(g.String(apiURL))
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req = req.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := req.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("top sellers request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("top sellers API returned status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var result OwnershipResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse top sellers response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.SecurityOwnerships) > 0 {
|
||||||
|
return result.SecurityOwnerships, nil
|
||||||
|
}
|
||||||
|
return result.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNewShareHolders fetches new institutional holders
|
||||||
|
func (c *BingClient) GetNewShareHolders(id string, count int) ([]Holder, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("no stock ID provided")
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
count = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sGetSecurityTopNewShareHolders/%s?rangeStart=1&count=%d",
|
||||||
|
BingAPIBaseURL,
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := c.client.Get(g.String(apiURL))
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req = req.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := req.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("new shareholders request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("new shareholders API returned status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var result OwnershipResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse new shareholders response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.SecurityOwnerships) > 0 {
|
||||||
|
return result.SecurityOwnerships, nil
|
||||||
|
}
|
||||||
|
return result.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExitedShareHolders fetches exited institutional holders
|
||||||
|
func (c *BingClient) GetExitedShareHolders(id string, count int) ([]Holder, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("no stock ID provided")
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
count = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sGetSecurityTopExitedShareHolders/%s?rangeStart=1&count=%d",
|
||||||
|
BingAPIBaseURL,
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := c.client.Get(g.String(apiURL))
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req = req.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := req.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("exited shareholders request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("exited shareholders API returned status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var result OwnershipResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse exited shareholders response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.SecurityOwnerships) > 0 {
|
||||||
|
return result.SecurityOwnerships, nil
|
||||||
|
}
|
||||||
|
return result.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInvestorDataAvailable checks if investor data exists for a stock
|
||||||
|
func (c *BingClient) IsInvestorDataAvailable(id string) (bool, error) {
|
||||||
|
if id == "" {
|
||||||
|
return false, fmt.Errorf("no stock ID provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sIsInvestorDataAvailable/%s",
|
||||||
|
BingAPIBaseURL,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := c.client.Get(g.String(apiURL))
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req = req.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := req.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return false, fmt.Errorf("investor data check request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
return false, fmt.Errorf("investor data check API returned status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var available bool
|
||||||
|
if err := json.Unmarshal([]byte(body), &available); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to parse investor data check response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return available, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllOwnership fetches all ownership data for a stock
|
||||||
|
func (c *BingClient) GetAllOwnership(id string, count int) (*OwnershipData, error) {
|
||||||
|
ownership := &OwnershipData{}
|
||||||
|
|
||||||
|
// Skip IsInvestorDataAvailable check as it often returns 404 even when data exists
|
||||||
|
// Just try to fetch the data directly
|
||||||
|
|
||||||
|
// Fetch all ownership data sequentially
|
||||||
|
if holders, err := c.GetTopShareHolders(id, count); err == nil {
|
||||||
|
ownership.TopHolders = holders
|
||||||
|
}
|
||||||
|
|
||||||
|
if buyers, err := c.GetTopBuyers(id, count); err == nil {
|
||||||
|
ownership.TopBuyers = buyers
|
||||||
|
}
|
||||||
|
|
||||||
|
if sellers, err := c.GetTopSellers(id, count); err == nil {
|
||||||
|
ownership.TopSellers = sellers
|
||||||
|
}
|
||||||
|
|
||||||
|
if newHolders, err := c.GetNewShareHolders(id, count); err == nil {
|
||||||
|
ownership.NewHolders = newHolders
|
||||||
|
}
|
||||||
|
|
||||||
|
if exited, err := c.GetExitedShareHolders(id, count); err == nil {
|
||||||
|
ownership.ExitedHolders = exited
|
||||||
|
}
|
||||||
|
|
||||||
|
return ownership, nil
|
||||||
|
}
|
||||||
1844
msn/idx_stocks.go
Normal file
1844
msn/idx_stocks.go
Normal file
File diff suppressed because it is too large
Load diff
652
msn/msn_client.go
Normal file
652
msn/msn_client.go
Normal file
|
|
@ -0,0 +1,652 @@
|
||||||
|
package msn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/enetx/g"
|
||||||
|
"github.com/enetx/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MSNClientConfig holds configuration for the MSN client
|
||||||
|
type MSNClientConfig struct {
|
||||||
|
Proxy string // Proxy URL (http://, https://, socks5://)
|
||||||
|
RateLimiter *RateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSNClient is the base client for MSN Finance APIs
|
||||||
|
type MSNClient struct {
|
||||||
|
client *surf.Client
|
||||||
|
proxy string
|
||||||
|
rateLimiter *RateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMSNClient creates a new MSN API client with Chrome impersonation
|
||||||
|
func NewMSNClient() *MSNClient {
|
||||||
|
return NewMSNClientWithConfig(MSNClientConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMSNClientWithConfig creates a new MSN API client with custom configuration
|
||||||
|
func NewMSNClientWithConfig(config MSNClientConfig) *MSNClient {
|
||||||
|
builder := surf.NewClient().
|
||||||
|
Builder().
|
||||||
|
Impersonate().
|
||||||
|
Chrome()
|
||||||
|
|
||||||
|
// Add proxy if configured
|
||||||
|
if config.Proxy != "" {
|
||||||
|
builder = builder.Proxy(g.String(config.Proxy))
|
||||||
|
}
|
||||||
|
|
||||||
|
client := builder.Build().Unwrap()
|
||||||
|
|
||||||
|
return &MSNClient{
|
||||||
|
client: client,
|
||||||
|
proxy: config.Proxy,
|
||||||
|
rateLimiter: config.RateLimiter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRateLimit waits for rate limiter if configured
|
||||||
|
func (c *MSNClient) waitForRateLimit() {
|
||||||
|
if c.rateLimiter != nil {
|
||||||
|
c.rateLimiter.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes idle connections
|
||||||
|
func (c *MSNClient) Close() {
|
||||||
|
c.client.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonHeaders returns common headers for MSN API requests
|
||||||
|
func (c *MSNClient) commonHeaders() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9,id;q=0.8",
|
||||||
|
"Origin": "https://www.msn.com",
|
||||||
|
"Referer": "https://www.msn.com/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQuotes fetches real-time quotes for given stock IDs
|
||||||
|
func (c *MSNClient) GetQuotes(ids []string) ([]QuoteData, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, fmt.Errorf("no stock IDs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.waitForRateLimit()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sFinance/Quotes?apikey=%s&ids=%s&wrapodata=false",
|
||||||
|
MSNAssetsBaseURL,
|
||||||
|
MSNAPIKey,
|
||||||
|
strings.Join(ids, ","),
|
||||||
|
)
|
||||||
|
|
||||||
|
req := c.client.Get(g.String(apiURL))
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
req = req.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := req.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("quotes request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("quotes API returned status %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var quotes []QuoteData
|
||||||
|
if err := json.Unmarshal([]byte(body), "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
|
||||||
|
}
|
||||||
228
msn/msn_screener.go
Normal file
228
msn/msn_screener.go
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
package msn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/enetx/g"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScreenerFilter represents available screener filter presets
|
||||||
|
type ScreenerFilter string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FilterTopPerformers ScreenerFilter = "top-performers"
|
||||||
|
FilterWorstPerformers ScreenerFilter = "worst-performers"
|
||||||
|
FilterHighDividend ScreenerFilter = "high-dividend"
|
||||||
|
FilterLowPE ScreenerFilter = "low-pe"
|
||||||
|
Filter52WeekHigh ScreenerFilter = "52w-high"
|
||||||
|
Filter52WeekLow ScreenerFilter = "52w-low"
|
||||||
|
FilterHighVolume ScreenerFilter = "high-volume"
|
||||||
|
FilterLargeMarketCap ScreenerFilter = "large-cap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter key mappings for MSN Screener API
|
||||||
|
var screenerFilterKeys = map[ScreenerFilter]string{
|
||||||
|
FilterTopPerformers: "st_list_topperfs",
|
||||||
|
FilterWorstPerformers: "st_list_poorperfs",
|
||||||
|
FilterHighDividend: "st_list_highdividend",
|
||||||
|
FilterLowPE: "st_list_lowpe",
|
||||||
|
Filter52WeekHigh: "st_list_52wkhi",
|
||||||
|
Filter52WeekLow: "st_list_52wklow",
|
||||||
|
FilterHighVolume: "st_list_highvol",
|
||||||
|
FilterLargeMarketCap: "st_list_largecap",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region key mappings for MSN Screener API
|
||||||
|
var screenerRegionKeys = map[string]string{
|
||||||
|
"id": "st_reg_id", // Indonesia
|
||||||
|
"us": "st_reg_us", // United States
|
||||||
|
"gb": "st_reg_gb", // United Kingdom
|
||||||
|
"de": "st_reg_de", // Germany
|
||||||
|
"jp": "st_reg_jp", // Japan
|
||||||
|
"hk": "st_reg_hk", // Hong Kong
|
||||||
|
"sg": "st_reg_sg", // Singapore
|
||||||
|
"au": "st_reg_au", // Australia
|
||||||
|
"in": "st_reg_in", // India
|
||||||
|
"cn": "st_reg_cn", // China
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenerConfig holds screener configuration
|
||||||
|
type ScreenerConfig struct {
|
||||||
|
Region string // Country code (e.g., "id" for Indonesia)
|
||||||
|
Filter ScreenerFilter // Filter preset
|
||||||
|
Limit int // Max results
|
||||||
|
PageIndex int // Page number (0-indexed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenerAPIResponse is the raw response from Finance/Screener
|
||||||
|
type ScreenerAPIResponse struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
MatchIDs []string `json:"matchIds"`
|
||||||
|
Quote []QuoteData `json:"quote"`
|
||||||
|
Equity []EquityData `json:"equity"`
|
||||||
|
Fund []interface{} `json:"fund"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScreener executes the stock screener with given configuration
|
||||||
|
func (c *MSNClient) RunScreener(config ScreenerConfig) (*ScreenerResponse, error) {
|
||||||
|
if config.Region == "" {
|
||||||
|
config.Region = "id" // Default to Indonesia
|
||||||
|
}
|
||||||
|
if config.Limit <= 0 {
|
||||||
|
config.Limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter array
|
||||||
|
filters := buildScreenerFilters(config.Region, config.Filter)
|
||||||
|
|
||||||
|
req := ScreenerRequest{
|
||||||
|
Filter: filters,
|
||||||
|
Order: ScreenerOrder{Key: "st_1yr_asc_order", Dir: "desc"},
|
||||||
|
ReturnValueType: []string{"quote", "equity"},
|
||||||
|
ScreenerType: "stock",
|
||||||
|
Limit: config.Limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal screener request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.waitForRateLimit()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%sFinance/Screener?apikey=%s&wrapodata=false",
|
||||||
|
MSNAssetsBaseURL,
|
||||||
|
MSNAPIKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
httpReq := c.client.Post(g.String(apiURL)).
|
||||||
|
SetHeaders("Content-Type", "text/plain;charset=UTF-8")
|
||||||
|
for k, v := range c.commonHeaders() {
|
||||||
|
httpReq = httpReq.SetHeaders(k, v)
|
||||||
|
}
|
||||||
|
httpReq = httpReq.Body(g.String(string(reqBody)))
|
||||||
|
|
||||||
|
resp := httpReq.Do()
|
||||||
|
if resp.IsErr() {
|
||||||
|
return nil, fmt.Errorf("screener request failed: %w", resp.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := resp.Ok()
|
||||||
|
if r.StatusCode != 200 {
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
return nil, fmt.Errorf("screener API returned status %d: %s", r.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := r.Body.String().Ok().Std()
|
||||||
|
|
||||||
|
var apiResp ScreenerAPIResponse
|
||||||
|
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse screener response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge quote and equity data into ScreenerStock
|
||||||
|
stocks := mergeScreenerResults(apiResp)
|
||||||
|
|
||||||
|
return &ScreenerResponse{
|
||||||
|
Value: stocks,
|
||||||
|
Total: apiResp.Count,
|
||||||
|
Count: apiResp.Count,
|
||||||
|
MatchIDs: apiResp.MatchIDs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScreenerFilters creates filter array based on region and preset
|
||||||
|
func buildScreenerFilters(region string, filter ScreenerFilter) []ScreenerFilterItem {
|
||||||
|
filters := make([]ScreenerFilterItem, 0, 2)
|
||||||
|
|
||||||
|
// Add filter preset
|
||||||
|
if filterKey, ok := screenerFilterKeys[filter]; ok {
|
||||||
|
filters = append(filters, ScreenerFilterItem{
|
||||||
|
Key: filterKey,
|
||||||
|
KeyGroup: "st_list_",
|
||||||
|
IsRange: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add region filter
|
||||||
|
if regionKey, ok := screenerRegionKeys[region]; ok {
|
||||||
|
filters = append(filters, ScreenerFilterItem{
|
||||||
|
Key: regionKey,
|
||||||
|
KeyGroup: "st_reg_",
|
||||||
|
IsRange: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeScreenerResults combines quote and equity data into ScreenerStock slice
|
||||||
|
func mergeScreenerResults(apiResp ScreenerAPIResponse) []ScreenerStock {
|
||||||
|
// Build equity map by instrumentId
|
||||||
|
equityMap := make(map[string]*EquityData)
|
||||||
|
for i := range apiResp.Equity {
|
||||||
|
eq := &apiResp.Equity[i]
|
||||||
|
// Use instrumentId from the "_p" field if available
|
||||||
|
if id := eq.ID; id != "" {
|
||||||
|
equityMap[id] = eq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stocks := make([]ScreenerStock, 0, len(apiResp.Quote))
|
||||||
|
for _, q := range apiResp.Quote {
|
||||||
|
stock := ScreenerStock{
|
||||||
|
ID: q.InstrumentID,
|
||||||
|
InstrumentID: q.InstrumentID,
|
||||||
|
Symbol: q.Symbol,
|
||||||
|
ShortName: q.ShortName,
|
||||||
|
DisplayName: q.DisplayName,
|
||||||
|
ExchangeID: q.ExchangeID,
|
||||||
|
ExchangeCode: q.ExchangeCode,
|
||||||
|
Country: q.Country,
|
||||||
|
Price: q.Price,
|
||||||
|
PriceChange: q.PriceChange,
|
||||||
|
PriceChangePct: q.PriceChangePct,
|
||||||
|
MarketCap: q.MarketCap,
|
||||||
|
Volume: q.AccumulatedVolume,
|
||||||
|
Price52wHigh: q.Price52wHigh,
|
||||||
|
Price52wLow: q.Price52wLow,
|
||||||
|
Return1Year: q.Return1Year,
|
||||||
|
ReturnYTD: q.ReturnYTD,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge equity data if available
|
||||||
|
if eq, ok := equityMap[q.InstrumentID]; ok {
|
||||||
|
stock.Sector = eq.Sector
|
||||||
|
stock.Industry = eq.Industry
|
||||||
|
}
|
||||||
|
|
||||||
|
stocks = append(stocks, stock)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseScreenerFilter converts string to ScreenerFilter
|
||||||
|
func ParseScreenerFilter(s string) (ScreenerFilter, error) {
|
||||||
|
switch s {
|
||||||
|
case "top-performers", "top":
|
||||||
|
return FilterTopPerformers, nil
|
||||||
|
case "worst-performers", "worst":
|
||||||
|
return FilterWorstPerformers, nil
|
||||||
|
case "high-dividend", "dividend":
|
||||||
|
return FilterHighDividend, nil
|
||||||
|
case "low-pe", "pe":
|
||||||
|
return FilterLowPE, nil
|
||||||
|
case "52w-high", "52high":
|
||||||
|
return Filter52WeekHigh, nil
|
||||||
|
case "52w-low", "52low":
|
||||||
|
return Filter52WeekLow, nil
|
||||||
|
case "high-volume", "volume":
|
||||||
|
return FilterHighVolume, nil
|
||||||
|
case "large-cap", "largecap":
|
||||||
|
return FilterLargeMarketCap, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown filter: %s (valid: top-performers, worst-performers, high-dividend, low-pe, 52w-high, 52w-low, high-volume, large-cap)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
353
msn/msn_stock.go
Normal file
353
msn/msn_stock.go
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
package msn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StockFetcher handles parallel fetching of stock data
|
||||||
|
type StockFetcher struct {
|
||||||
|
msnClient *MSNClient
|
||||||
|
bingClient *BingClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStockFetcher creates a new stock fetcher
|
||||||
|
func NewStockFetcher() *StockFetcher {
|
||||||
|
return &StockFetcher{
|
||||||
|
msnClient: NewMSNClient(),
|
||||||
|
bingClient: NewBingClient(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all clients
|
||||||
|
func (f *StockFetcher) Close() {
|
||||||
|
f.msnClient.Close()
|
||||||
|
f.bingClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchResult holds the result of fetching a single stock
|
||||||
|
type StockFetchResult struct {
|
||||||
|
Index int
|
||||||
|
Stock *StockData
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchStocks fetches data for multiple stocks in parallel
|
||||||
|
func (f *StockFetcher) FetchStocks(ctx context.Context, ids []string, concurrency int) []StockData {
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]StockData, len(ids))
|
||||||
|
|
||||||
|
// Create work channel
|
||||||
|
work := make(chan int, len(ids))
|
||||||
|
for i := range ids {
|
||||||
|
work <- i
|
||||||
|
}
|
||||||
|
close(work)
|
||||||
|
|
||||||
|
// Worker pool
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
for w := 0; w < concurrency; w++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case idx, ok := <-work:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := ids[idx]
|
||||||
|
stock := f.fetchSingleStock(ctx, id)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = *stock
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// Count successful fetches
|
||||||
|
successCount := 0
|
||||||
|
for k, v := range stock.FetchStatus {
|
||||||
|
if v == "ok" {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
_ = k
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%d/%d] %s (%s) - %d/%d APIs succeeded",
|
||||||
|
idx+1, len(ids),
|
||||||
|
stock.Ticker,
|
||||||
|
stock.ID,
|
||||||
|
successCount,
|
||||||
|
len(stock.FetchStatus),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchSingleStock fetches all data for a single stock
|
||||||
|
func (f *StockFetcher) fetchSingleStock(ctx context.Context, id string) *StockData {
|
||||||
|
stock := &StockData{
|
||||||
|
ID: id,
|
||||||
|
FetchedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
FetchStatus: make(map[string]string),
|
||||||
|
Charts: make(map[string][]ChartPoint),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use channels for parallel fetching within a single stock
|
||||||
|
type fetchResult struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
data interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultChan := make(chan fetchResult, 10)
|
||||||
|
var fetchWg sync.WaitGroup
|
||||||
|
|
||||||
|
// Fetch quote
|
||||||
|
fetchWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer fetchWg.Done()
|
||||||
|
quotes, err := f.msnClient.GetQuotes([]string{id})
|
||||||
|
if err != nil {
|
||||||
|
resultChan <- fetchResult{name: "quote", err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(quotes) > 0 {
|
||||||
|
resultChan <- fetchResult{name: "quote", data: "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
|
||||||
|
}
|
||||||
550
msn/msn_types.go
Normal file
550
msn/msn_types.go
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
package msn
|
||||||
|
|
||||||
|
// MSN API Constants
|
||||||
|
const (
|
||||||
|
MSNAssetsBaseURL = "https://assets.msn.com/service/"
|
||||||
|
MSNAPIBaseURL = "https://api.msn.com/msn/v0/pages/finance/"
|
||||||
|
BingAPIBaseURL = "https://services.bingapis.com/contentservices-finance.hedgefunddataprovider/api/v1/"
|
||||||
|
|
||||||
|
// Public API key from MSN Money website
|
||||||
|
MSNAPIKey = "0QfOX3Vn51YCzitbLaRkTTBadtWpgTN8NZLW0C1SEM"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScreenerRequest is the POST body for Finance/Screener
|
||||||
|
// Uses the actual MSN API format with predefined filter keys
|
||||||
|
type ScreenerRequest struct {
|
||||||
|
Filter []ScreenerFilterItem `json:"filter"`
|
||||||
|
Order ScreenerOrder `json:"order"`
|
||||||
|
ReturnValueType []string `json:"returnValueType"`
|
||||||
|
ScreenerType string `json:"screenerType"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenerFilterItem represents a filter condition in the screener
|
||||||
|
type ScreenerFilterItem struct {
|
||||||
|
Key string `json:"key"` // e.g., "st_list_topperfs", "st_reg_id"
|
||||||
|
KeyGroup string `json:"keyGroup"` // e.g., "st_list_", "st_reg_"
|
||||||
|
IsRange bool `json:"isRange"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenerOrder represents sort order for screener results
|
||||||
|
type ScreenerOrder struct {
|
||||||
|
Key string `json:"key"` // e.g., "st_1yr_asc_order"
|
||||||
|
Dir string `json:"dir"` // "asc" or "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenerResponse from Finance/Screener
|
||||||
|
type ScreenerResponse struct {
|
||||||
|
Value []ScreenerStock `json:"value"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
MatchIDs []string `json:"matchIds"`
|
||||||
|
Equity []ScreenerStock `json:"equity"`
|
||||||
|
Quote []QuoteData `json:"quote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenerStock is a stock from screener results
|
||||||
|
type ScreenerStock struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InstrumentID string `json:"instrumentId,omitempty"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
ShortName string `json:"shortName"`
|
||||||
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
|
ExchangeID string `json:"exchangeId"`
|
||||||
|
ExchangeCode string `json:"exchangeCode,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Sector string `json:"sector,omitempty"`
|
||||||
|
Industry string `json:"industry,omitempty"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PriceChange float64 `json:"priceChange"`
|
||||||
|
PriceChangePct float64 `json:"priceChangePercent"`
|
||||||
|
MarketCap float64 `json:"marketCap"`
|
||||||
|
Volume float64 `json:"accumulatedVolume"`
|
||||||
|
Price52wHigh float64 `json:"price52wHigh"`
|
||||||
|
Price52wLow float64 `json:"price52wLow"`
|
||||||
|
Return1Year float64 `json:"return1Year"`
|
||||||
|
ReturnYTD float64 `json:"returnYTD"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteResponse from Finance/Quotes
|
||||||
|
type QuoteResponse []QuoteData
|
||||||
|
|
||||||
|
// QuoteData represents real-time quote data
|
||||||
|
type QuoteData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InstrumentID string `json:"instrumentId"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
ShortName string `json:"shortName"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
PriceChange float64 `json:"priceChange"`
|
||||||
|
PriceChangePct float64 `json:"priceChangePercent"`
|
||||||
|
PriceDayOpen float64 `json:"priceDayOpen"`
|
||||||
|
PriceDayHigh float64 `json:"priceDayHigh"`
|
||||||
|
PriceDayLow float64 `json:"priceDayLow"`
|
||||||
|
PricePreviousClose float64 `json:"pricePreviousClose"`
|
||||||
|
PriceClose float64 `json:"priceClose"`
|
||||||
|
Price52wHigh float64 `json:"price52wHigh"`
|
||||||
|
Price52wLow float64 `json:"price52wLow"`
|
||||||
|
AccumulatedVolume float64 `json:"accumulatedVolume"`
|
||||||
|
AverageVolume float64 `json:"averageVolume"`
|
||||||
|
MarketCap float64 `json:"marketCap"`
|
||||||
|
MarketCapCurrency string `json:"marketCapCurrency"`
|
||||||
|
ExchangeID string `json:"exchangeId"`
|
||||||
|
ExchangeCode string `json:"exchangeCode"`
|
||||||
|
ExchangeName string `json:"exchangeName"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
TimeLastTraded string `json:"timeLastTraded"`
|
||||||
|
TimeLastUpdated string `json:"timeLastUpdated"`
|
||||||
|
// Historical price changes
|
||||||
|
PriceChange1Week float64 `json:"priceChange1Week"`
|
||||||
|
PriceChange1Month float64 `json:"priceChange1Month"`
|
||||||
|
PriceChange3Month float64 `json:"priceChange3Month"`
|
||||||
|
PriceChange6Month float64 `json:"priceChange6Month"`
|
||||||
|
PriceChangeYTD float64 `json:"priceChangeYTD"`
|
||||||
|
PriceChange1Year float64 `json:"priceChange1Year"`
|
||||||
|
// Historical returns (percentage)
|
||||||
|
Return1Week float64 `json:"return1Week"`
|
||||||
|
Return1Month float64 `json:"return1Month"`
|
||||||
|
Return3Month float64 `json:"return3Month"`
|
||||||
|
Return6Month float64 `json:"return6Month"`
|
||||||
|
ReturnYTD float64 `json:"returnYTD"`
|
||||||
|
Return1Year float64 `json:"return1Year"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteSummaryResponse from Finance/QuoteSummary
|
||||||
|
type QuoteSummaryResponse []struct {
|
||||||
|
Quotes []QuoteData `json:"quotes"`
|
||||||
|
Exchanges []ExchangeData `json:"exchanges"`
|
||||||
|
Details []QuoteDetail `json:"quoteDetails"`
|
||||||
|
ChartData []ChartResponse `json:"charts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeData from Finance/Exchanges
|
||||||
|
type ExchangeData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Timezone string `json:"timeZone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteDetail provides extended quote information
|
||||||
|
type QuoteDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Beta float64 `json:"beta"`
|
||||||
|
TrailingPE float64 `json:"trailingPE"`
|
||||||
|
ForwardPE float64 `json:"forwardPE"`
|
||||||
|
PriceToBook float64 `json:"priceToBook"`
|
||||||
|
PriceToSales float64 `json:"priceToSales"`
|
||||||
|
EnterpriseValue float64 `json:"enterpriseValue"`
|
||||||
|
EBITDA float64 `json:"ebitda"`
|
||||||
|
Revenue float64 `json:"revenue"`
|
||||||
|
GrossProfit float64 `json:"grossProfit"`
|
||||||
|
FreeCashFlow float64 `json:"freeCashFlow"`
|
||||||
|
DebtToEquity float64 `json:"debtToEquity"`
|
||||||
|
QuickRatio float64 `json:"quickRatio"`
|
||||||
|
CurrentRatio float64 `json:"currentRatio"`
|
||||||
|
ReturnOnEquity float64 `json:"returnOnEquity"`
|
||||||
|
ReturnOnAssets float64 `json:"returnOnAssets"`
|
||||||
|
ProfitMargin float64 `json:"profitMargin"`
|
||||||
|
OperatingMargin float64 `json:"operatingMargin"`
|
||||||
|
GrossMargin float64 `json:"grossMargin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChartResponse from Finance/Charts
|
||||||
|
type ChartResponse struct {
|
||||||
|
ID string `json:"_p"`
|
||||||
|
ChartType string `json:"chartType"` // "1D1M", "1M", "3M", "1Y", "3Y"
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Series ChartSeriesData `json:"series"`
|
||||||
|
Points []ChartPoint `json:"-"` // Computed from Series
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChartSeriesData is the raw series data from the API
|
||||||
|
type ChartSeriesData struct {
|
||||||
|
TimeStamps []string `json:"timeStamps"`
|
||||||
|
Prices []float64 `json:"prices"`
|
||||||
|
OpenPrices []float64 `json:"openPrices"`
|
||||||
|
PricesHigh []float64 `json:"pricesHigh"`
|
||||||
|
PricesLow []float64 `json:"pricesLow"`
|
||||||
|
Volumes []float64 `json:"volumes"`
|
||||||
|
StartTime string `json:"startTime"`
|
||||||
|
EndTime string `json:"endTime"`
|
||||||
|
PriceHigh float64 `json:"priceHigh"`
|
||||||
|
PriceLow float64 `json:"priceLow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToChartPoints converts the series data into chart points
|
||||||
|
func (c *ChartResponse) ToChartPoints() []ChartPoint {
|
||||||
|
if len(c.Series.TimeStamps) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
points := make([]ChartPoint, len(c.Series.TimeStamps))
|
||||||
|
for i, ts := range c.Series.TimeStamps {
|
||||||
|
point := ChartPoint{Time: ts}
|
||||||
|
|
||||||
|
if i < len(c.Series.Prices) {
|
||||||
|
point.Price = c.Series.Prices[i]
|
||||||
|
point.Close = c.Series.Prices[i]
|
||||||
|
}
|
||||||
|
if i < len(c.Series.OpenPrices) {
|
||||||
|
point.Open = c.Series.OpenPrices[i]
|
||||||
|
}
|
||||||
|
if i < len(c.Series.PricesHigh) {
|
||||||
|
point.High = c.Series.PricesHigh[i]
|
||||||
|
}
|
||||||
|
if i < len(c.Series.PricesLow) {
|
||||||
|
point.Low = c.Series.PricesLow[i]
|
||||||
|
}
|
||||||
|
if i < len(c.Series.Volumes) {
|
||||||
|
point.Volume = int64(c.Series.Volumes[i])
|
||||||
|
}
|
||||||
|
points[i] = point
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChartPoint is a single data point in a chart
|
||||||
|
type ChartPoint struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Open float64 `json:"open"`
|
||||||
|
High float64 `json:"high"`
|
||||||
|
Low float64 `json:"low"`
|
||||||
|
Close float64 `json:"close"`
|
||||||
|
Volume int64 `json:"volume"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EquityResponse from Finance/Equities
|
||||||
|
type EquityResponse []EquityData
|
||||||
|
|
||||||
|
// EquityData represents company information
|
||||||
|
type EquityData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
ShortName string `json:"shortName"`
|
||||||
|
LongName string `json:"longName"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Sector string `json:"sector"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Employees int `json:"fullTimeEmployees"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Officers []Officer `json:"officers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Officer represents a company executive
|
||||||
|
type Officer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Age int `json:"age"`
|
||||||
|
YearBorn int `json:"yearBorn"`
|
||||||
|
TotalPay int64 `json:"totalPay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinancialStatementsResponse from Finance/Equities/financialstatements
|
||||||
|
// Response is an array of FinancialStatement objects
|
||||||
|
type FinancialStatementsResponse []FinancialStatement
|
||||||
|
|
||||||
|
// FinancialStatement represents comprehensive financial data
|
||||||
|
type FinancialStatement struct {
|
||||||
|
UnderlyingInstrument InstrumentInfo `json:"underlyingInstrument"`
|
||||||
|
BalanceSheets *BalanceSheet `json:"balanceSheets"`
|
||||||
|
CashFlow *CashFlowData `json:"cashFlow"`
|
||||||
|
IncomeStatements *IncomeStatement `json:"incomeStatements"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstrumentInfo contains basic stock information
|
||||||
|
type InstrumentInfo struct {
|
||||||
|
InstrumentID string `json:"instrumentId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
ShortName string `json:"shortName"`
|
||||||
|
ExchangeID string `json:"exchangeId"`
|
||||||
|
ExchangeCode string `json:"exchangeCode"`
|
||||||
|
SecurityType string `json:"securityType"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BalanceSheet represents balance sheet data
|
||||||
|
type BalanceSheet struct {
|
||||||
|
CurrentAssets map[string]float64 `json:"currentAssets"`
|
||||||
|
LongTermAssets map[string]float64 `json:"longTermAssets"`
|
||||||
|
CurrentLiabilities map[string]float64 `json:"currentLiabilities"`
|
||||||
|
Equity map[string]float64 `json:"equity"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceDate string `json:"sourceDate"`
|
||||||
|
ReportDate string `json:"reportDate"`
|
||||||
|
EndDate string `json:"endDate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CashFlowData represents cash flow statement
|
||||||
|
type CashFlowData struct {
|
||||||
|
Financing map[string]float64 `json:"financing"`
|
||||||
|
Investing map[string]float64 `json:"investing"`
|
||||||
|
Operating map[string]float64 `json:"operating"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
EndDate string `json:"endDate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncomeStatement represents income statement data
|
||||||
|
type IncomeStatement struct {
|
||||||
|
Revenue map[string]float64 `json:"revenue"`
|
||||||
|
Expenses map[string]float64 `json:"expenses"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
EndDate string `json:"endDate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyRatiosResponse from api.msn.com keyratios
|
||||||
|
type KeyRatiosResponse []KeyRatios
|
||||||
|
|
||||||
|
// KeyRatios represents financial ratios with historical data
|
||||||
|
type KeyRatios struct {
|
||||||
|
StockID string `json:"stockId"`
|
||||||
|
ExchangeID string `json:"exchangeId"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
ShortName string `json:"shortName"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
IndustryMetrics []IndustryMetric `json:"industryMetrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndustryMetric represents financial metrics for a specific year
|
||||||
|
type IndustryMetric struct {
|
||||||
|
Year string `json:"year"`
|
||||||
|
FiscalPeriodType string `json:"fiscalPeriodType"`
|
||||||
|
RevenuePerShare float64 `json:"revenuePerShare"`
|
||||||
|
EarningsPerShare float64 `json:"earningsPerShare"`
|
||||||
|
FreeCashFlowPerShare float64 `json:"freeCashFlowPerShare"`
|
||||||
|
DividendPerShare float64 `json:"dividendPerShare"`
|
||||||
|
BookValuePerShare float64 `json:"bookValuePerShare"`
|
||||||
|
RevenueGrowthRate float64 `json:"revenueGrowthRate"`
|
||||||
|
EarningsGrowthRate float64 `json:"earningsGrowthRate"`
|
||||||
|
GrossMargin float64 `json:"grossMargin"`
|
||||||
|
OperatingMargin float64 `json:"operatingMargin"`
|
||||||
|
NetMargin float64 `json:"netMargin"`
|
||||||
|
ROE float64 `json:"roe"`
|
||||||
|
ROIC float64 `json:"roic"`
|
||||||
|
ROA float64 `json:"returnOnAssetCurrent"`
|
||||||
|
DebtToEquityRatio float64 `json:"debtToEquityRatio"`
|
||||||
|
DebtToEBITDA float64 `json:"debtToEbitda"`
|
||||||
|
FinancialLeverage float64 `json:"financialLeverage"`
|
||||||
|
QuickRatio float64 `json:"quickRatio"`
|
||||||
|
CurrentRatio float64 `json:"currentRatio"`
|
||||||
|
AssetTurnover float64 `json:"assetTurnover"`
|
||||||
|
InventoryTurnover float64 `json:"inventoryTurnover"`
|
||||||
|
ReceivableTurnover float64 `json:"receivableTurnover"`
|
||||||
|
PayoutRatio float64 `json:"payoutRatio"`
|
||||||
|
PriceToSalesRatio float64 `json:"priceToSalesRatio"`
|
||||||
|
PriceToEarningsRatio float64 `json:"priceToEarningsRatio"`
|
||||||
|
PriceToCashFlowRatio float64 `json:"priceToCashFlowRatio"`
|
||||||
|
PriceToBookRatio float64 `json:"priceToBookRatio"`
|
||||||
|
EVToEBITDA float64 `json:"evEbitda"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarningsAPIResponse represents the actual API response from Finance/Events/Earnings
|
||||||
|
type EarningsAPIResponse struct {
|
||||||
|
History struct {
|
||||||
|
Quarterly map[string]EarningsData `json:"quarterly"`
|
||||||
|
Annual map[string]EarningsData `json:"annual"`
|
||||||
|
} `json:"History"`
|
||||||
|
InstrumentID string `json:"InstrumentId"`
|
||||||
|
Symbol string `json:"Symbol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarningsData represents a single earnings report from the API
|
||||||
|
type EarningsData struct {
|
||||||
|
EpsActual float64 `json:"EpsActual"`
|
||||||
|
EpsSurprise float64 `json:"EpsSurprise"`
|
||||||
|
EpsSurprisePercent float64 `json:"EpsSurprisePercent"`
|
||||||
|
EpsForecast float64 `json:"EpsForecast"`
|
||||||
|
RevenueActual float64 `json:"RevenueActual"`
|
||||||
|
RevenueSurprise float64 `json:"RevenueSurprise"`
|
||||||
|
RevenueForecast float64 `json:"RevenueForecast"`
|
||||||
|
EarningReleaseDate string `json:"EarningReleaseDate"`
|
||||||
|
CiqFiscalPeriodType string `json:"CiqFiscalPeriodType"` // e.g., "Q42025", "Q12026"
|
||||||
|
CalendarPeriodType string `json:"CalendarPeriodType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarningsEvent represents a normalized earnings event for storage
|
||||||
|
type EarningsEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EventDate string `json:"eventDate"`
|
||||||
|
FiscalYear int `json:"fiscalYear"`
|
||||||
|
FiscalQuarter int `json:"fiscalQuarter"`
|
||||||
|
EPSEstimate float64 `json:"epsEstimate"`
|
||||||
|
EPSActual float64 `json:"epsActual"`
|
||||||
|
EPSSurprise float64 `json:"epsSurprise"`
|
||||||
|
EPSSurprisePct float64 `json:"epsSurprisePercent"`
|
||||||
|
RevenueEstimate float64 `json:"revenueEstimate"`
|
||||||
|
RevenueActual float64 `json:"revenueActual"`
|
||||||
|
RevenueSurprise float64 `json:"revenueSurprise"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentimentResponse from Finance/SentimentBrowser
|
||||||
|
type SentimentResponse []SentimentData
|
||||||
|
|
||||||
|
// SentimentData represents market sentiment for a stock
|
||||||
|
type SentimentData struct {
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Market string `json:"market"`
|
||||||
|
InstrumentID string `json:"instrumentId"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
SentimentStatistics []SentimentStatistic `json:"sentimentStatistics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentimentStatistic represents sentiment data for a time period
|
||||||
|
type SentimentStatistic struct {
|
||||||
|
StartTime int64 `json:"startTime"`
|
||||||
|
EndTime int64 `json:"endTime"`
|
||||||
|
TimeRangeName string `json:"timeRangeName"`
|
||||||
|
TimeRangeEnum string `json:"timeRangeEnum"`
|
||||||
|
Bullish int `json:"bullish"`
|
||||||
|
Bearish int `json:"bearish"`
|
||||||
|
Neutral int `json:"neutral"`
|
||||||
|
BullishPercent float64 `json:"bullishPercent"`
|
||||||
|
BearishPercent float64 `json:"bearishPercent"`
|
||||||
|
NeutralPercent float64 `json:"neutralPercent"`
|
||||||
|
Scenario string `json:"scenairo"` // Note: API has typo "scenairo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsightsResponse from api.msn.com insights
|
||||||
|
type InsightsResponse []InsightData
|
||||||
|
|
||||||
|
// InsightData represents AI-generated stock insights
|
||||||
|
type InsightData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Highlights []string `json:"highlights"`
|
||||||
|
Risks []string `json:"risks"`
|
||||||
|
LastUpdated string `json:"lastUpdated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewsFeedResponse from MSN/Feed/me
|
||||||
|
type NewsFeedResponse struct {
|
||||||
|
Value []NewsItem `json:"value"`
|
||||||
|
SubCards []NewsItem `json:"subCards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewsItem represents a news article
|
||||||
|
type NewsItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"abstract"`
|
||||||
|
Provider *NewsProvider `json:"provider"`
|
||||||
|
PublishTime string `json:"publishedDateTime"`
|
||||||
|
Images []NewsImage `json:"images"`
|
||||||
|
ReadTimeMin int `json:"readTimeMin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewsProvider represents a news provider
|
||||||
|
type NewsProvider struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewsImage represents a news article image
|
||||||
|
type NewsImage struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holder represents an institutional holder
|
||||||
|
type Holder struct {
|
||||||
|
Name string `json:"investorName"`
|
||||||
|
Type string `json:"investorType"`
|
||||||
|
SharesHeld int64 `json:"sharesHeld"`
|
||||||
|
SharesChange int64 `json:"sharesChange"`
|
||||||
|
SharesPct float64 `json:"sharesPercent"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
ReportDate string `json:"reportDate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnershipResponse from Bing API
|
||||||
|
type OwnershipResponse struct {
|
||||||
|
Records []Holder `json:"records"`
|
||||||
|
SecurityOwnerships []Holder `json:"securityOwnerships"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnershipData aggregates all ownership information
|
||||||
|
type OwnershipData struct {
|
||||||
|
TopHolders []Holder `json:"top_holders"`
|
||||||
|
TopBuyers []Holder `json:"top_buyers"`
|
||||||
|
TopSellers []Holder `json:"top_sellers"`
|
||||||
|
NewHolders []Holder `json:"new_holders"`
|
||||||
|
ExitedHolders []Holder `json:"exited_holders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StockData is the complete stock information output
|
||||||
|
type StockData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ticker string `json:"ticker"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Exchange string `json:"exchange"`
|
||||||
|
Sector string `json:"sector"`
|
||||||
|
Industry string `json:"industry"`
|
||||||
|
|
||||||
|
// Real-time data
|
||||||
|
Quote *QuoteData `json:"quote,omitempty"`
|
||||||
|
|
||||||
|
// Historical Charts
|
||||||
|
Charts map[string][]ChartPoint `json:"charts,omitempty"`
|
||||||
|
|
||||||
|
// Fundamentals
|
||||||
|
Financials *FinancialData `json:"financials,omitempty"`
|
||||||
|
KeyRatios *KeyRatios `json:"key_ratios,omitempty"`
|
||||||
|
|
||||||
|
// Company Info
|
||||||
|
Company *EquityData `json:"company,omitempty"`
|
||||||
|
|
||||||
|
// Events
|
||||||
|
Earnings []EarningsEvent `json:"earnings,omitempty"`
|
||||||
|
|
||||||
|
// Analysis
|
||||||
|
Sentiment *SentimentData `json:"sentiment,omitempty"`
|
||||||
|
Insights *InsightData `json:"insights,omitempty"`
|
||||||
|
|
||||||
|
// Ownership (Bing API)
|
||||||
|
Ownership *OwnershipData `json:"ownership,omitempty"`
|
||||||
|
|
||||||
|
// News
|
||||||
|
News []NewsItem `json:"news,omitempty"`
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
FetchedAt string `json:"fetched_at"`
|
||||||
|
FetchStatus map[string]string `json:"fetch_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinancialData aggregates all financial statements
|
||||||
|
type FinancialData struct {
|
||||||
|
Statements []FinancialStatement `json:"statements,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenerOutput is the JSON output for screener command
|
||||||
|
type ScreenerOutput struct {
|
||||||
|
Filter string `json:"filter"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Stocks []ScreenerStock `json:"stocks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchOutput is the JSON output for fetch command
|
||||||
|
type FetchOutput struct {
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Stocks []StockData `json:"stocks"`
|
||||||
|
}
|
||||||
194
msn/news_analysis.go
Normal file
194
msn/news_analysis.go
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package msn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// News categories
|
||||||
|
const (
|
||||||
|
CategoryEarnings = "earnings"
|
||||||
|
CategoryDividend = "dividend"
|
||||||
|
CategoryCorporateAction = "corporate_action"
|
||||||
|
CategoryRegulation = "regulation"
|
||||||
|
CategoryRating = "rating"
|
||||||
|
CategoryExpansion = "expansion"
|
||||||
|
CategoryLeadership = "leadership"
|
||||||
|
CategoryMarket = "market"
|
||||||
|
CategoryGeneral = "general"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentiment types
|
||||||
|
const (
|
||||||
|
SentimentPositive = "positive"
|
||||||
|
SentimentNegative = "negative"
|
||||||
|
SentimentNeutral = "neutral"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Category keywords (Indonesian + English)
|
||||||
|
var categoryKeywords = map[string][]string{
|
||||||
|
CategoryEarnings: {
|
||||||
|
"laba", "rugi", "earnings", "profit", "net income", "pendapatan",
|
||||||
|
"revenue", "keuntungan", "kerugian", "loss", "income", "untung",
|
||||||
|
"quarterly", "kuartalan", "annual report", "laporan tahunan",
|
||||||
|
"eps", "earning per share",
|
||||||
|
},
|
||||||
|
CategoryDividend: {
|
||||||
|
"dividen", "dividend", "pembagian", "interim", "final dividend",
|
||||||
|
"cum date", "ex date", "payment date", "tanggal pembayaran",
|
||||||
|
"yield", "payout",
|
||||||
|
},
|
||||||
|
CategoryCorporateAction: {
|
||||||
|
"akuisisi", "merger", "acquisition", "rights issue", "stock split",
|
||||||
|
"reverse split", "buyback", "ipo", "penawaran umum", "private placement",
|
||||||
|
"tender offer", "spin off", "spinoff", "demerger", "konsolidasi",
|
||||||
|
"rights", "waran", "warrant", "obligasi", "bond", "sukuk",
|
||||||
|
},
|
||||||
|
CategoryRegulation: {
|
||||||
|
"ojk", "regulasi", "peraturan", "kebijakan", "regulation", "policy",
|
||||||
|
"compliance", "kepatuhan", "lisensi", "license", "izin", "permit",
|
||||||
|
"pemerintah", "government", "bapepam", "bei", "idx", "bursa",
|
||||||
|
},
|
||||||
|
CategoryRating: {
|
||||||
|
"rating", "peringkat", "upgrade", "downgrade", "outlook",
|
||||||
|
"stable", "positive", "negative", "credit rating", "moody",
|
||||||
|
"fitch", "s&p", "pefindo", "target price", "rekomendasi",
|
||||||
|
"buy", "sell", "hold", "analyst",
|
||||||
|
},
|
||||||
|
CategoryExpansion: {
|
||||||
|
"ekspansi", "expansion", "investasi", "investment", "proyek baru",
|
||||||
|
"new project", "pabrik", "factory", "plant", "cabang", "branch",
|
||||||
|
"pembangunan", "construction", "development", "joint venture", "jv",
|
||||||
|
"kerjasama", "partnership", "kontrak", "contract",
|
||||||
|
},
|
||||||
|
CategoryLeadership: {
|
||||||
|
"direktur", "director", "komisaris", "commissioner", "ceo", "cfo",
|
||||||
|
"president director", "management", "manajemen", "direksi",
|
||||||
|
"rups", "agm", "annual general meeting", "pengangkatan", "appointment",
|
||||||
|
"pengunduran", "resignation", "pergantian", "change",
|
||||||
|
},
|
||||||
|
CategoryMarket: {
|
||||||
|
"ihsg", "idx", "pasar modal", "bursa", "market", "saham",
|
||||||
|
"stock", "trading", "perdagangan", "volume", "kapitalisasi",
|
||||||
|
"market cap", "blue chip", "lq45", "idx80", "kompas100",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positive sentiment keywords
|
||||||
|
var positiveKeywords = []string{
|
||||||
|
// Indonesian
|
||||||
|
"naik", "untung", "tumbuh", "positif", "optimis", "meningkat",
|
||||||
|
"surplus", "berhasil", "sukses", "cemerlang", "bagus", "baik",
|
||||||
|
"membaik", "melonjak", "meroket", "tertinggi", "rekor",
|
||||||
|
"peningkatan", "pertumbuhan", "keuntungan", "laba bersih",
|
||||||
|
"ekspansi", "pemulihan", "recovery",
|
||||||
|
// English
|
||||||
|
"rise", "gain", "growth", "positive", "optimistic", "increase",
|
||||||
|
"surplus", "success", "excellent", "good", "improve", "surge",
|
||||||
|
"soar", "highest", "record", "profit", "expansion", "recovery",
|
||||||
|
"bullish", "upgrade", "beat", "exceed", "outperform",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative sentiment keywords
|
||||||
|
var negativeKeywords = []string{
|
||||||
|
// Indonesian
|
||||||
|
"turun", "rugi", "anjlok", "negatif", "pesimis", "menurun",
|
||||||
|
"defisit", "gagal", "buruk", "memburuk", "jatuh", "tertekan",
|
||||||
|
"terendah", "penurunan", "kerugian", "merosot", "melemah",
|
||||||
|
"default", "bangkrut", "pailit", "koreksi", "tekanan",
|
||||||
|
// English
|
||||||
|
"fall", "loss", "plunge", "negative", "pessimistic", "decrease",
|
||||||
|
"deficit", "fail", "bad", "worsen", "drop", "pressure",
|
||||||
|
"lowest", "decline", "weak", "default", "bankrupt", "correction",
|
||||||
|
"bearish", "downgrade", "miss", "underperform", "concern", "risk",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Critical news keywords (alerts)
|
||||||
|
var criticalKeywords = []string{
|
||||||
|
// Indonesian
|
||||||
|
"suspend", "suspensi", "fraud", "penipuan", "korupsi", "corruption",
|
||||||
|
"default", "gagal bayar", "bangkrut", "pailit", "bankruptcy",
|
||||||
|
"delisting", "pencabutan", "investigasi", "investigation",
|
||||||
|
"skandal", "scandal", "illegal", "ilegal", "pelanggaran", "violation",
|
||||||
|
"tuntutan", "lawsuit", "gugatan", "denda", "fine", "sanksi", "sanction",
|
||||||
|
"pkpu", "penundaan", "moratorium", "restrukturisasi utang",
|
||||||
|
// English
|
||||||
|
"suspend", "fraud", "corruption", "default", "bankrupt", "bankruptcy",
|
||||||
|
"delisting", "investigation", "scandal", "illegal", "violation",
|
||||||
|
"lawsuit", "fine", "sanction", "debt restructuring", "warning",
|
||||||
|
"material adverse", "going concern", "audit opinion", "disclaimer",
|
||||||
|
}
|
||||||
|
|
||||||
|
// categorizeNews determines the category of a news article
|
||||||
|
func categorizeNews(title, abstract string) string {
|
||||||
|
text := strings.ToLower(title + " " + abstract)
|
||||||
|
|
||||||
|
// Check each category
|
||||||
|
maxScore := 0
|
||||||
|
bestCategory := CategoryGeneral
|
||||||
|
|
||||||
|
for category, keywords := range categoryKeywords {
|
||||||
|
score := 0
|
||||||
|
for _, keyword := range keywords {
|
||||||
|
if strings.Contains(text, keyword) {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if score > maxScore {
|
||||||
|
maxScore = score
|
||||||
|
bestCategory = category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreNewsSentiment analyzes sentiment of a news article
|
||||||
|
func scoreNewsSentiment(title, abstract string) (sentiment string, score float64) {
|
||||||
|
text := strings.ToLower(title + " " + abstract)
|
||||||
|
|
||||||
|
positiveScore := 0
|
||||||
|
negativeScore := 0
|
||||||
|
|
||||||
|
for _, keyword := range positiveKeywords {
|
||||||
|
if strings.Contains(text, keyword) {
|
||||||
|
positiveScore++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, keyword := range negativeKeywords {
|
||||||
|
if strings.Contains(text, keyword) {
|
||||||
|
negativeScore++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalScore := positiveScore + negativeScore
|
||||||
|
if totalScore == 0 {
|
||||||
|
return SentimentNeutral, 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate score from -1 (very negative) to +1 (very positive)
|
||||||
|
score = float64(positiveScore-negativeScore) / float64(totalScore)
|
||||||
|
|
||||||
|
if score > 0.2 {
|
||||||
|
sentiment = SentimentPositive
|
||||||
|
} else if score < -0.2 {
|
||||||
|
sentiment = SentimentNegative
|
||||||
|
} else {
|
||||||
|
sentiment = SentimentNeutral
|
||||||
|
}
|
||||||
|
|
||||||
|
return sentiment, score
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewsCritical checks if news contains critical/alert-worthy content
|
||||||
|
func isNewsCritical(title, abstract string) bool {
|
||||||
|
text := strings.ToLower(title + " " + abstract)
|
||||||
|
|
||||||
|
for _, keyword := range criticalKeywords {
|
||||||
|
if strings.Contains(text, keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
97
msn/rate_limiter.go
Normal file
97
msn/rate_limiter.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package msn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiter implements a token bucket rate limiter with random delay
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens float64
|
||||||
|
maxTokens float64
|
||||||
|
refillRate float64 // tokens per second
|
||||||
|
lastRefill time.Time
|
||||||
|
minDelayMs int // minimum delay in milliseconds
|
||||||
|
maxDelayMs int // maximum delay in milliseconds
|
||||||
|
requestCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiterConfig holds rate limiter configuration
|
||||||
|
type RateLimiterConfig struct {
|
||||||
|
RequestsPerSecond float64 // target RPS
|
||||||
|
MinDelayMs int // minimum random delay
|
||||||
|
MaxDelayMs int // maximum random delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter creates a new rate limiter
|
||||||
|
func NewRateLimiter(config RateLimiterConfig) *RateLimiter {
|
||||||
|
if config.RequestsPerSecond <= 0 {
|
||||||
|
config.RequestsPerSecond = 10 // default 10 RPS
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RateLimiter{
|
||||||
|
tokens: config.RequestsPerSecond, // start with full bucket
|
||||||
|
maxTokens: config.RequestsPerSecond,
|
||||||
|
refillRate: config.RequestsPerSecond,
|
||||||
|
lastRefill: time.Now(),
|
||||||
|
minDelayMs: config.MinDelayMs,
|
||||||
|
maxDelayMs: config.MaxDelayMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until a token is available and applies random delay
|
||||||
|
func (r *RateLimiter) Wait() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// Refill tokens based on elapsed time
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(r.lastRefill).Seconds()
|
||||||
|
r.tokens += elapsed * r.refillRate
|
||||||
|
if r.tokens > r.maxTokens {
|
||||||
|
r.tokens = r.maxTokens
|
||||||
|
}
|
||||||
|
r.lastRefill = now
|
||||||
|
|
||||||
|
// Wait if no tokens available
|
||||||
|
if r.tokens < 1 {
|
||||||
|
waitTime := time.Duration((1-r.tokens)/r.refillRate*1000) * time.Millisecond
|
||||||
|
r.mu.Unlock()
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
r.mu.Lock()
|
||||||
|
r.tokens = 0
|
||||||
|
} else {
|
||||||
|
r.tokens--
|
||||||
|
}
|
||||||
|
|
||||||
|
r.requestCount++
|
||||||
|
|
||||||
|
// Apply random delay if configured
|
||||||
|
if r.maxDelayMs > 0 {
|
||||||
|
delayRange := r.maxDelayMs - r.minDelayMs
|
||||||
|
if delayRange <= 0 {
|
||||||
|
delayRange = 1
|
||||||
|
}
|
||||||
|
delay := r.minDelayMs + rand.Intn(delayRange)
|
||||||
|
r.mu.Unlock()
|
||||||
|
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||||
|
r.mu.Lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestCount returns the total number of requests made
|
||||||
|
func (r *RateLimiter) RequestCount() int64 {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.requestCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRPS dynamically adjusts the rate limit
|
||||||
|
func (r *RateLimiter) SetRPS(rps float64) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.maxTokens = rps
|
||||||
|
r.refillRate = rps
|
||||||
|
}
|
||||||
12
pyproject.toml
Normal file
12
pyproject.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[project]
|
||||||
|
name = "rubick"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"lxml-html-clean>=0.4.3",
|
||||||
|
"newspaper4k>=0.9.4.1",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
|
"pandas>=3.0.0",
|
||||||
|
"xlsxwriter>=3.2.9",
|
||||||
|
]
|
||||||
31
scripts/e2e_run.sh
Executable file
31
scripts/e2e_run.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_BIN="${1:-./bin/rubick}"
|
||||||
|
if [[ ! -x "$APP_BIN" ]]; then
|
||||||
|
echo "error: binary not executable: $APP_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
OUT="output/$TS"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
echo "timestamp=$TS" > "$OUT/RUN_INFO.txt"
|
||||||
|
|
||||||
|
echo "[1/5] msn fetch-all"
|
||||||
|
"$APP_BIN" msn fetch-all --index idx30 --limit 3 --db "$OUT/stocks.db" --rps 10 --delay 100-150 --concurrency 2
|
||||||
|
|
||||||
|
echo "[2/5] news plain"
|
||||||
|
"$APP_BIN" news IHSG --from 2026-03-01 --to 2026-03-05 --count 2 --concurrency 2 --output "$OUT/news_plain.json"
|
||||||
|
|
||||||
|
echo "[3/5] export simple csv"
|
||||||
|
"$APP_BIN" export simple --db "$OUT/stocks.db" --format csv --output "$OUT/csv"
|
||||||
|
|
||||||
|
echo "[4/5] export dashboard"
|
||||||
|
"$APP_BIN" export dashboard --db "$OUT/stocks.db" --output "$OUT/dashboard.xlsx"
|
||||||
|
|
||||||
|
echo "[5/5] export history"
|
||||||
|
"$APP_BIN" export history --db "$OUT/stocks.db" --output "$OUT/history.xlsx"
|
||||||
|
|
||||||
|
echo "E2E completed: $OUT"
|
||||||
1373
scripts/export_dashboard.py
Normal file
1373
scripts/export_dashboard.py
Normal file
File diff suppressed because it is too large
Load diff
626
scripts/export_history.py
Normal file
626
scripts/export_history.py
Normal file
|
|
@ -0,0 +1,626 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
History Excel Export Script - Professional Edition
|
||||||
|
|
||||||
|
Exports historical data (price history, ratio history, sentiment history) to Excel
|
||||||
|
with proper tables and conditional formatting. No charts - tables only.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python scripts/export_history.py --db output/stocks.db --output output/history.xlsx
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import xlsxwriter
|
||||||
|
from xlsxwriter.utility import xl_range
|
||||||
|
|
||||||
|
# Color palette - matches export_dashboard.py
|
||||||
|
COLORS = {
|
||||||
|
'primary_dark': '#1F4E79',
|
||||||
|
'primary': '#2E75B6',
|
||||||
|
'primary_light': '#5B9BD5',
|
||||||
|
'success_dark': '#375623',
|
||||||
|
'success': '#70AD47',
|
||||||
|
'success_light': '#C6EFCE',
|
||||||
|
'success_text': '#006100',
|
||||||
|
'danger_dark': '#833C0C',
|
||||||
|
'danger': '#C00000',
|
||||||
|
'danger_light': '#FFC7CE',
|
||||||
|
'danger_text': '#9C0006',
|
||||||
|
'warning_dark': '#7F6000',
|
||||||
|
'warning': '#FFC000',
|
||||||
|
'warning_light': '#FFEB9C',
|
||||||
|
'warning_text': '#9C5700',
|
||||||
|
'white': '#FFFFFF',
|
||||||
|
'light_gray': '#F2F2F2',
|
||||||
|
'dark_gray': '#404040',
|
||||||
|
}
|
||||||
|
|
||||||
|
TABLE_STYLE = 'Table Style Medium 2'
|
||||||
|
|
||||||
|
|
||||||
|
def safe_float(value, default=None) -> Optional[float]:
|
||||||
|
"""Safely convert to float."""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryExporter:
|
||||||
|
"""Professional History Excel Exporter."""
|
||||||
|
|
||||||
|
def __init__(self, workbook: xlsxwriter.Workbook, conn: sqlite3.Connection,
|
||||||
|
start_date: str = None, end_date: str = None, stock_id: str = None):
|
||||||
|
self.wb = workbook
|
||||||
|
self.conn = conn
|
||||||
|
self.start_date = start_date
|
||||||
|
self.end_date = end_date
|
||||||
|
self.stock_id = stock_id
|
||||||
|
self._setup_formats()
|
||||||
|
|
||||||
|
def _setup_formats(self):
|
||||||
|
"""Setup formatting styles - matches export_dashboard.py."""
|
||||||
|
self.fmt_title = self.wb.add_format({
|
||||||
|
'bold': True, 'font_size': 18, 'font_color': COLORS['primary_dark'],
|
||||||
|
'bottom': 2, 'bottom_color': COLORS['primary_dark']
|
||||||
|
})
|
||||||
|
self.fmt_subtitle = self.wb.add_format({
|
||||||
|
'bold': True, 'font_size': 12, 'font_color': COLORS['primary']
|
||||||
|
})
|
||||||
|
self.fmt_section = self.wb.add_format({
|
||||||
|
'bold': True, 'font_size': 14, 'font_color': COLORS['white'],
|
||||||
|
'bg_color': COLORS['primary_dark'], 'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
self.fmt_header = self.wb.add_format({
|
||||||
|
'bold': True, 'font_color': COLORS['white'],
|
||||||
|
'bg_color': COLORS['primary_dark'], 'align': 'center', 'valign': 'vcenter',
|
||||||
|
'border': 1, 'text_wrap': True
|
||||||
|
})
|
||||||
|
self.fmt_num = self.wb.add_format({'num_format': '#,##0.00', 'align': 'right'})
|
||||||
|
self.fmt_num_0 = self.wb.add_format({'num_format': '#,##0', 'align': 'right'})
|
||||||
|
self.fmt_pct = self.wb.add_format({'num_format': '0.00"%"', 'align': 'right'})
|
||||||
|
self.fmt_pct_signed = self.wb.add_format({'num_format': '+0.00%;-0.00%;0.00%', 'align': 'right'})
|
||||||
|
self.fmt_date = self.wb.add_format({'num_format': 'yyyy-mm-dd', 'align': 'center'})
|
||||||
|
self.fmt_kpi_value = self.wb.add_format({
|
||||||
|
'bold': True, 'font_size': 24, 'font_color': COLORS['primary_dark'],
|
||||||
|
'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
self.fmt_kpi_label = self.wb.add_format({
|
||||||
|
'font_size': 10, 'font_color': COLORS['dark_gray'],
|
||||||
|
'align': 'center', 'valign': 'vcenter', 'bold': True
|
||||||
|
})
|
||||||
|
self.fmt_kpi_box = self.wb.add_format({
|
||||||
|
'bg_color': COLORS['light_gray'], 'border': 1, 'border_color': COLORS['primary_light']
|
||||||
|
})
|
||||||
|
|
||||||
|
def _add_table(self, ws, start_row: int, start_col: int, data: list,
|
||||||
|
columns: list, table_name: str, total_row: bool = False) -> int:
|
||||||
|
"""Add a proper Excel Table with filtering and sorting."""
|
||||||
|
if not data:
|
||||||
|
ws.write(start_row, start_col, "No data available")
|
||||||
|
return start_row + 1
|
||||||
|
|
||||||
|
end_row = start_row + len(data)
|
||||||
|
end_col = start_col + len(columns) - 1
|
||||||
|
|
||||||
|
# Build table columns
|
||||||
|
table_columns = []
|
||||||
|
for col_def in columns:
|
||||||
|
col_opt = {'header': col_def['header']}
|
||||||
|
if col_def.get('total_function'):
|
||||||
|
col_opt['total_function'] = col_def['total_function']
|
||||||
|
if col_def.get('total_string'):
|
||||||
|
col_opt['total_string'] = col_def['total_string']
|
||||||
|
if col_def.get('format'):
|
||||||
|
col_opt['format'] = col_def['format']
|
||||||
|
table_columns.append(col_opt)
|
||||||
|
|
||||||
|
# Write data
|
||||||
|
for row_idx, row_data in enumerate(data):
|
||||||
|
for col_idx, col_def in enumerate(columns):
|
||||||
|
key = col_def.get('key')
|
||||||
|
value = row_data.get(key) if key else None
|
||||||
|
|
||||||
|
transform = col_def.get('transform')
|
||||||
|
if transform and value is not None:
|
||||||
|
value = transform(value)
|
||||||
|
|
||||||
|
fmt = col_def.get('format')
|
||||||
|
ws.write(start_row + 1 + row_idx, start_col + col_idx, value, fmt)
|
||||||
|
|
||||||
|
# Add table
|
||||||
|
table_range = xl_range(start_row, start_col, end_row, end_col)
|
||||||
|
ws.add_table(table_range, {
|
||||||
|
'name': table_name,
|
||||||
|
'style': TABLE_STYLE,
|
||||||
|
'columns': table_columns,
|
||||||
|
'total_row': total_row,
|
||||||
|
'autofilter': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set column widths
|
||||||
|
for col_idx, col_def in enumerate(columns):
|
||||||
|
width = col_def.get('width', 12)
|
||||||
|
ws.set_column(start_col + col_idx, start_col + col_idx, width)
|
||||||
|
|
||||||
|
return end_row + (2 if total_row else 1)
|
||||||
|
|
||||||
|
def _get_stocks(self) -> list:
|
||||||
|
"""Get all stocks."""
|
||||||
|
cursor = self.conn.execute("""
|
||||||
|
SELECT id, ticker, name, exchange_code as exchange
|
||||||
|
FROM stocks ORDER BY ticker
|
||||||
|
""")
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def _get_price_history(self) -> list:
|
||||||
|
"""Get price history with filters."""
|
||||||
|
query = """
|
||||||
|
SELECT ph.*, s.ticker, s.name
|
||||||
|
FROM price_history ph
|
||||||
|
JOIN stocks s ON ph.stock_id = s.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if self.stock_id:
|
||||||
|
query += " AND ph.stock_id = ?"
|
||||||
|
params.append(self.stock_id)
|
||||||
|
if self.start_date:
|
||||||
|
query += " AND ph.scrape_date >= ?"
|
||||||
|
params.append(self.start_date)
|
||||||
|
if self.end_date:
|
||||||
|
query += " AND ph.scrape_date <= ?"
|
||||||
|
params.append(self.end_date)
|
||||||
|
|
||||||
|
query += " ORDER BY s.ticker, ph.scrape_date DESC"
|
||||||
|
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
|
||||||
|
|
||||||
|
def _get_ratios_history(self) -> list:
|
||||||
|
"""Get ratios history with filters."""
|
||||||
|
query = """
|
||||||
|
SELECT rh.*, s.ticker, s.name
|
||||||
|
FROM ratios_history rh
|
||||||
|
JOIN stocks s ON rh.stock_id = s.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if self.stock_id:
|
||||||
|
query += " AND rh.stock_id = ?"
|
||||||
|
params.append(self.stock_id)
|
||||||
|
if self.start_date:
|
||||||
|
query += " AND rh.scrape_date >= ?"
|
||||||
|
params.append(self.start_date)
|
||||||
|
if self.end_date:
|
||||||
|
query += " AND rh.scrape_date <= ?"
|
||||||
|
params.append(self.end_date)
|
||||||
|
|
||||||
|
query += " ORDER BY s.ticker, rh.scrape_date DESC"
|
||||||
|
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
|
||||||
|
|
||||||
|
def _get_sentiment_history(self) -> list:
|
||||||
|
"""Get sentiment history with filters."""
|
||||||
|
query = """
|
||||||
|
SELECT sh.*, s.ticker, s.name
|
||||||
|
FROM sentiment_history sh
|
||||||
|
JOIN stocks s ON sh.stock_id = s.id
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if self.stock_id:
|
||||||
|
query += " AND sh.stock_id = ?"
|
||||||
|
params.append(self.stock_id)
|
||||||
|
if self.start_date:
|
||||||
|
query += " AND sh.scrape_date >= ?"
|
||||||
|
params.append(self.start_date)
|
||||||
|
if self.end_date:
|
||||||
|
query += " AND sh.scrape_date <= ?"
|
||||||
|
params.append(self.end_date)
|
||||||
|
|
||||||
|
query += " ORDER BY s.ticker, sh.scrape_date DESC"
|
||||||
|
return [dict(row) for row in self.conn.execute(query, params).fetchall()]
|
||||||
|
|
||||||
|
def _get_scrape_runs(self) -> list:
|
||||||
|
"""Get scrape runs."""
|
||||||
|
cursor = self.conn.execute("""
|
||||||
|
SELECT * FROM scrape_runs ORDER BY started_at DESC
|
||||||
|
""")
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def create_summary_sheet(self, stocks: list, price_history: list,
|
||||||
|
ratios_history: list, sentiment_history: list,
|
||||||
|
scrape_runs: list):
|
||||||
|
"""Create summary sheet."""
|
||||||
|
ws = self.wb.add_worksheet("Summary")
|
||||||
|
|
||||||
|
# Title
|
||||||
|
ws.merge_range('A1:F1', "Historical Data Summary", self.fmt_title)
|
||||||
|
ws.set_row(0, 30)
|
||||||
|
ws.write('A2', f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", self.fmt_subtitle)
|
||||||
|
|
||||||
|
# KPI cards
|
||||||
|
row = 4
|
||||||
|
kpis = [
|
||||||
|
("Total Stocks", str(len(stocks))),
|
||||||
|
("Price Records", str(len(price_history))),
|
||||||
|
("Ratio Records", str(len(ratios_history))),
|
||||||
|
("Sentiment Records", str(len(sentiment_history))),
|
||||||
|
("Scrape Runs", str(len(scrape_runs))),
|
||||||
|
]
|
||||||
|
|
||||||
|
col = 0
|
||||||
|
for label, value in kpis:
|
||||||
|
ws.merge_range(row, col, row + 1, col + 1, '', self.fmt_kpi_box)
|
||||||
|
ws.write(row, col, label, self.fmt_kpi_label)
|
||||||
|
ws.write(row + 1, col, value, self.fmt_kpi_value)
|
||||||
|
col += 2
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
row = 7
|
||||||
|
ws.write(row, 0, "Data Date Range:", self.fmt_subtitle)
|
||||||
|
|
||||||
|
if price_history:
|
||||||
|
dates = [p['scrape_date'] for p in price_history if p.get('scrape_date')]
|
||||||
|
if dates:
|
||||||
|
ws.write(row + 1, 0, f"From: {min(dates)}")
|
||||||
|
ws.write(row + 2, 0, f"To: {max(dates)}")
|
||||||
|
|
||||||
|
# Scrape runs summary table
|
||||||
|
row = 11
|
||||||
|
ws.merge_range(row, 0, row, 5, "Recent Scrape Runs", self.fmt_section)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
run_data = []
|
||||||
|
for r in scrape_runs[:10]:
|
||||||
|
started = r.get('started_at', '')
|
||||||
|
finished = r.get('finished_at', '')
|
||||||
|
|
||||||
|
duration = ''
|
||||||
|
if started and finished:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.fromisoformat(started.replace('Z', '+00:00'))
|
||||||
|
finish_dt = datetime.fromisoformat(finished.replace('Z', '+00:00'))
|
||||||
|
delta = finish_dt - start_dt
|
||||||
|
duration = str(delta)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
run_data.append({
|
||||||
|
'id': r.get('id'),
|
||||||
|
'status': r.get('status'),
|
||||||
|
'index': r.get('index_name'),
|
||||||
|
'total': r.get('total_stocks'),
|
||||||
|
'success': r.get('success'),
|
||||||
|
'failed': r.get('failed'),
|
||||||
|
'duration': duration,
|
||||||
|
})
|
||||||
|
|
||||||
|
run_cols = [
|
||||||
|
{'header': 'Run ID', 'key': 'id', 'width': 8},
|
||||||
|
{'header': 'Status', 'key': 'status', 'width': 12},
|
||||||
|
{'header': 'Index', 'key': 'index', 'width': 10},
|
||||||
|
{'header': 'Total', 'key': 'total', 'width': 8, 'format': self.fmt_num_0},
|
||||||
|
{'header': 'Success', 'key': 'success', 'width': 8, 'format': self.fmt_num_0},
|
||||||
|
{'header': 'Failed', 'key': 'failed', 'width': 8, 'format': self.fmt_num_0},
|
||||||
|
{'header': 'Duration', 'key': 'duration', 'width': 15},
|
||||||
|
]
|
||||||
|
|
||||||
|
self._add_table(ws, row, 0, run_data, run_cols, 'ScrapeRuns')
|
||||||
|
|
||||||
|
ws.set_column('A:G', 12)
|
||||||
|
|
||||||
|
def create_price_history_sheet(self, data: list):
|
||||||
|
"""Create price history sheet."""
|
||||||
|
ws = self.wb.add_worksheet("Price History")
|
||||||
|
|
||||||
|
ws.merge_range('A1:N1', "Price History", self.fmt_title)
|
||||||
|
ws.set_row(0, 25)
|
||||||
|
|
||||||
|
row = 3
|
||||||
|
price_data = [{
|
||||||
|
'ticker': p['ticker'],
|
||||||
|
'name': p['name'],
|
||||||
|
'date': p.get('scrape_date', ''),
|
||||||
|
'price': safe_float(p.get('price')),
|
||||||
|
'change': safe_float(p.get('price_change')),
|
||||||
|
'change_pct': safe_float(p.get('price_change_pct'), 0) / 100 if p.get('price_change_pct') else None,
|
||||||
|
'open': safe_float(p.get('price_open')),
|
||||||
|
'high': safe_float(p.get('price_high')),
|
||||||
|
'low': safe_float(p.get('price_low')),
|
||||||
|
'volume': safe_float(p.get('volume')),
|
||||||
|
'market_cap': safe_float(p.get('market_cap')),
|
||||||
|
'high_52w': safe_float(p.get('price_52w_high')),
|
||||||
|
'low_52w': safe_float(p.get('price_52w_low')),
|
||||||
|
'ytd_pct': safe_float(p.get('price_change_ytd'), 0) / 100 if p.get('price_change_ytd') else None,
|
||||||
|
} for p in data]
|
||||||
|
|
||||||
|
price_cols = [
|
||||||
|
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
|
||||||
|
{'header': 'Name', 'key': 'name', 'width': 22},
|
||||||
|
{'header': 'Date', 'key': 'date', 'width': 11},
|
||||||
|
{'header': 'Price', 'key': 'price', 'width': 10, 'format': self.fmt_num},
|
||||||
|
{'header': 'Change', 'key': 'change', 'width': 10, 'format': self.fmt_num},
|
||||||
|
{'header': 'Chg%', 'key': 'change_pct', 'width': 8, 'format': self.fmt_pct_signed},
|
||||||
|
{'header': 'Open', 'key': 'open', 'width': 10, 'format': self.fmt_num},
|
||||||
|
{'header': 'High', 'key': 'high', 'width': 10, 'format': self.fmt_num},
|
||||||
|
{'header': 'Low', 'key': 'low', 'width': 10, 'format': self.fmt_num},
|
||||||
|
{'header': 'Volume', 'key': 'volume', 'width': 14, 'format': self.fmt_num_0},
|
||||||
|
{'header': 'Market Cap', 'key': 'market_cap', 'width': 15, 'format': self.fmt_num_0},
|
||||||
|
{'header': '52W High', 'key': 'high_52w', 'width': 10, 'format': self.fmt_num},
|
||||||
|
{'header': '52W Low', 'key': 'low_52w', 'width': 10, 'format': self.fmt_num},
|
||||||
|
{'header': 'YTD%', 'key': 'ytd_pct', 'width': 8, 'format': self.fmt_pct_signed},
|
||||||
|
]
|
||||||
|
|
||||||
|
self._add_table(ws, row, 0, price_data, price_cols, 'PriceHistory')
|
||||||
|
|
||||||
|
# Conditional formatting on change %
|
||||||
|
if price_data:
|
||||||
|
data_end = row + len(price_data)
|
||||||
|
ws.conditional_format(row + 1, 5, data_end, 5, {
|
||||||
|
'type': '3_color_scale',
|
||||||
|
'min_color': '#F8696B',
|
||||||
|
'mid_color': '#FFEB84',
|
||||||
|
'max_color': '#63BE7B',
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.freeze_panes(4, 2)
|
||||||
|
|
||||||
|
def create_ratios_history_sheet(self, data: list):
|
||||||
|
"""Create ratios history sheet."""
|
||||||
|
ws = self.wb.add_worksheet("Ratios History")
|
||||||
|
|
||||||
|
ws.merge_range('A1:W1', "Financial Ratios History", self.fmt_title)
|
||||||
|
ws.set_row(0, 25)
|
||||||
|
|
||||||
|
row = 3
|
||||||
|
ratio_data = [{
|
||||||
|
'ticker': r['ticker'],
|
||||||
|
'name': r['name'],
|
||||||
|
'date': r.get('scrape_date', ''),
|
||||||
|
'year': r.get('year', ''),
|
||||||
|
'pe': safe_float(r.get('pe_ratio')),
|
||||||
|
'pb': safe_float(r.get('pb_ratio')),
|
||||||
|
'ps': safe_float(r.get('ps_ratio')),
|
||||||
|
'pcf': safe_float(r.get('pcf_ratio')),
|
||||||
|
'ev_ebitda': safe_float(r.get('ev_ebitda')),
|
||||||
|
'roe': safe_float(r.get('roe')),
|
||||||
|
'roa': safe_float(r.get('roa')),
|
||||||
|
'roic': safe_float(r.get('roic')),
|
||||||
|
'gross': safe_float(r.get('gross_margin')),
|
||||||
|
'op_margin': safe_float(r.get('operating_margin')),
|
||||||
|
'net_margin': safe_float(r.get('net_margin')),
|
||||||
|
'de': safe_float(r.get('debt_to_equity')),
|
||||||
|
'current': safe_float(r.get('current_ratio')),
|
||||||
|
'quick': safe_float(r.get('quick_ratio')),
|
||||||
|
'div_yield': safe_float(r.get('dividend_yield')),
|
||||||
|
'payout': safe_float(r.get('payout_ratio')),
|
||||||
|
'eps': safe_float(r.get('eps')),
|
||||||
|
'bvps': safe_float(r.get('bvps')),
|
||||||
|
'rev_gr': safe_float(r.get('revenue_growth')),
|
||||||
|
} for r in data]
|
||||||
|
|
||||||
|
ratio_cols = [
|
||||||
|
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
|
||||||
|
{'header': 'Name', 'key': 'name', 'width': 20},
|
||||||
|
{'header': 'Date', 'key': 'date', 'width': 11},
|
||||||
|
{'header': 'Year', 'key': 'year', 'width': 6},
|
||||||
|
{'header': 'P/E', 'key': 'pe', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'P/B', 'key': 'pb', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'P/S', 'key': 'ps', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'P/CF', 'key': 'pcf', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'EV/EBITDA', 'key': 'ev_ebitda', 'width': 9, 'format': self.fmt_num},
|
||||||
|
{'header': 'ROE%', 'key': 'roe', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'ROA%', 'key': 'roa', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'ROIC%', 'key': 'roic', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'Gross%', 'key': 'gross', 'width': 8, 'format': self.fmt_num},
|
||||||
|
{'header': 'Op%', 'key': 'op_margin', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'Net%', 'key': 'net_margin', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'D/E', 'key': 'de', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'Current', 'key': 'current', 'width': 8, 'format': self.fmt_num},
|
||||||
|
{'header': 'Quick', 'key': 'quick', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'Yield%', 'key': 'div_yield', 'width': 7, 'format': self.fmt_num},
|
||||||
|
{'header': 'Payout%', 'key': 'payout', 'width': 8, 'format': self.fmt_num},
|
||||||
|
{'header': 'EPS', 'key': 'eps', 'width': 8, 'format': self.fmt_num},
|
||||||
|
{'header': 'BVPS', 'key': 'bvps', 'width': 9, 'format': self.fmt_num},
|
||||||
|
{'header': 'RevGr%', 'key': 'rev_gr', 'width': 8, 'format': self.fmt_num},
|
||||||
|
]
|
||||||
|
|
||||||
|
self._add_table(ws, row, 0, ratio_data, ratio_cols, 'RatiosHistory')
|
||||||
|
|
||||||
|
# Conditional formatting on ROE
|
||||||
|
if ratio_data:
|
||||||
|
data_end = row + len(ratio_data)
|
||||||
|
ws.conditional_format(row + 1, 9, data_end, 9, {
|
||||||
|
'type': 'data_bar',
|
||||||
|
'bar_color': COLORS['success'],
|
||||||
|
'bar_solid': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.freeze_panes(4, 2)
|
||||||
|
|
||||||
|
def create_sentiment_history_sheet(self, data: list):
|
||||||
|
"""Create sentiment history sheet."""
|
||||||
|
ws = self.wb.add_worksheet("Sentiment History")
|
||||||
|
|
||||||
|
ws.merge_range('A1:K1', "Sentiment History", self.fmt_title)
|
||||||
|
ws.set_row(0, 25)
|
||||||
|
|
||||||
|
row = 3
|
||||||
|
sent_data = [{
|
||||||
|
'ticker': s['ticker'],
|
||||||
|
'name': s['name'],
|
||||||
|
'date': s.get('scrape_date', ''),
|
||||||
|
'time_range': s.get('time_range', ''),
|
||||||
|
'bullish': safe_float(s.get('bullish_pct')),
|
||||||
|
'bearish': safe_float(s.get('bearish_pct')),
|
||||||
|
'neutral': safe_float(s.get('neutral_pct')),
|
||||||
|
'bull_count': safe_float(s.get('bullish')),
|
||||||
|
'bear_count': safe_float(s.get('bearish')),
|
||||||
|
'neut_count': safe_float(s.get('neutral')),
|
||||||
|
'net': (safe_float(s.get('bullish_pct'), 0) - safe_float(s.get('bearish_pct'), 0)),
|
||||||
|
} for s in data]
|
||||||
|
|
||||||
|
sent_cols = [
|
||||||
|
{'header': 'Ticker', 'key': 'ticker', 'width': 8},
|
||||||
|
{'header': 'Name', 'key': 'name', 'width': 22},
|
||||||
|
{'header': 'Date', 'key': 'date', 'width': 11},
|
||||||
|
{'header': 'Period', 'key': 'time_range', 'width': 15},
|
||||||
|
{'header': 'Bull%', 'key': 'bullish', 'width': 8, 'format': self.fmt_num},
|
||||||
|
{'header': 'Bear%', 'key': 'bearish', 'width': 8, 'format': self.fmt_num},
|
||||||
|
{'header': 'Neut%', 'key': 'neutral', 'width': 8, 'format': self.fmt_num},
|
||||||
|
{'header': 'Bulls', 'key': 'bull_count', 'width': 8, 'format': self.fmt_num_0},
|
||||||
|
{'header': 'Bears', 'key': 'bear_count', 'width': 8, 'format': self.fmt_num_0},
|
||||||
|
{'header': 'Neutral', 'key': 'neut_count', 'width': 8, 'format': self.fmt_num_0},
|
||||||
|
{'header': 'Net', 'key': 'net', 'width': 8, 'format': self.fmt_num},
|
||||||
|
]
|
||||||
|
|
||||||
|
self._add_table(ws, row, 0, sent_data, sent_cols, 'SentimentHistory')
|
||||||
|
|
||||||
|
# Conditional formatting
|
||||||
|
if sent_data:
|
||||||
|
data_end = row + len(sent_data)
|
||||||
|
# Bullish data bar
|
||||||
|
ws.conditional_format(row + 1, 4, data_end, 4, {
|
||||||
|
'type': 'data_bar',
|
||||||
|
'bar_color': COLORS['success'],
|
||||||
|
'bar_solid': True,
|
||||||
|
})
|
||||||
|
# Bearish data bar
|
||||||
|
ws.conditional_format(row + 1, 5, data_end, 5, {
|
||||||
|
'type': 'data_bar',
|
||||||
|
'bar_color': COLORS['danger'],
|
||||||
|
'bar_solid': True,
|
||||||
|
})
|
||||||
|
# Net sentiment 3-color
|
||||||
|
ws.conditional_format(row + 1, 10, data_end, 10, {
|
||||||
|
'type': '3_color_scale',
|
||||||
|
'min_color': '#F8696B',
|
||||||
|
'mid_color': '#FFEB84',
|
||||||
|
'max_color': '#63BE7B',
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.freeze_panes(4, 2)
|
||||||
|
|
||||||
|
def create_price_pivot_sheet(self, stocks: list, price_history: list):
|
||||||
|
"""Create price pivot table (dates as rows, stocks as columns)."""
|
||||||
|
ws = self.wb.add_worksheet("Price Pivot")
|
||||||
|
|
||||||
|
ws.merge_range('A1:C1', "Price Matrix (Pivot)", self.fmt_title)
|
||||||
|
ws.set_row(0, 25)
|
||||||
|
|
||||||
|
if not price_history or not stocks:
|
||||||
|
ws.write(3, 0, "No data available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get unique dates (limit to 365)
|
||||||
|
dates = sorted(set(p['scrape_date'] for p in price_history if p.get('scrape_date')),
|
||||||
|
reverse=True)[:365]
|
||||||
|
|
||||||
|
if not dates:
|
||||||
|
ws.write(3, 0, "No date data available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Limit to 100 stocks
|
||||||
|
stock_list = stocks[:100]
|
||||||
|
|
||||||
|
# Build price lookup
|
||||||
|
price_lookup = {}
|
||||||
|
for p in price_history:
|
||||||
|
key = (p['stock_id'], p['scrape_date'])
|
||||||
|
price_lookup[key] = p.get('price')
|
||||||
|
|
||||||
|
# Header row with tickers
|
||||||
|
row = 3
|
||||||
|
ws.write(row, 0, "Date", self.fmt_header)
|
||||||
|
for col, stock in enumerate(stock_list, 1):
|
||||||
|
ws.write(row, col, stock['ticker'], self.fmt_header)
|
||||||
|
|
||||||
|
# Data rows
|
||||||
|
for row_idx, date in enumerate(dates):
|
||||||
|
ws.write(row + 1 + row_idx, 0, date)
|
||||||
|
for col_idx, stock in enumerate(stock_list, 1):
|
||||||
|
price = price_lookup.get((stock['id'], date))
|
||||||
|
if price:
|
||||||
|
ws.write(row + 1 + row_idx, col_idx, price, self.fmt_num)
|
||||||
|
|
||||||
|
ws.freeze_panes(4, 1)
|
||||||
|
ws.set_column(0, 0, 12)
|
||||||
|
ws.set_column(1, 100, 10)
|
||||||
|
|
||||||
|
def generate(self):
|
||||||
|
"""Generate all history sheets."""
|
||||||
|
print("Loading data...")
|
||||||
|
stocks = self._get_stocks()
|
||||||
|
price_history = self._get_price_history()
|
||||||
|
ratios_history = self._get_ratios_history()
|
||||||
|
sentiment_history = self._get_sentiment_history()
|
||||||
|
scrape_runs = self._get_scrape_runs()
|
||||||
|
|
||||||
|
print("Creating Summary sheet...")
|
||||||
|
self.create_summary_sheet(stocks, price_history, ratios_history,
|
||||||
|
sentiment_history, scrape_runs)
|
||||||
|
|
||||||
|
print("Creating Price History sheet...")
|
||||||
|
self.create_price_history_sheet(price_history)
|
||||||
|
|
||||||
|
print("Creating Ratios History sheet...")
|
||||||
|
self.create_ratios_history_sheet(ratios_history)
|
||||||
|
|
||||||
|
print("Creating Sentiment History sheet...")
|
||||||
|
self.create_sentiment_history_sheet(sentiment_history)
|
||||||
|
|
||||||
|
if stocks and price_history:
|
||||||
|
print("Creating Price Pivot sheet...")
|
||||||
|
self.create_price_pivot_sheet(stocks, price_history)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Export historical stock data to professional Excel"
|
||||||
|
)
|
||||||
|
parser.add_argument('--db', required=True, help='Path to SQLite database')
|
||||||
|
parser.add_argument('--output', '-o', required=True, help='Output Excel file path')
|
||||||
|
parser.add_argument('--start-date', help='Start date filter (YYYY-MM-DD)')
|
||||||
|
parser.add_argument('--end-date', help='End date filter (YYYY-MM-DD)')
|
||||||
|
parser.add_argument('--stock', help='Filter by stock ID')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not Path(args.db).exists():
|
||||||
|
print(f"Error: Database not found: {args.db}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
conn = sqlite3.connect(args.db)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
workbook = xlsxwriter.Workbook(args.output, {
|
||||||
|
'constant_memory': False,
|
||||||
|
'strings_to_urls': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
exporter = HistoryExporter(
|
||||||
|
workbook, conn,
|
||||||
|
start_date=args.start_date,
|
||||||
|
end_date=args.end_date,
|
||||||
|
stock_id=args.stock
|
||||||
|
)
|
||||||
|
exporter.generate()
|
||||||
|
|
||||||
|
workbook.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\nHistory exported to: {args.output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exit(main())
|
||||||
81
scripts/export_simple.py
Normal file
81
scripts/export_simple.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simple table export from SQLite to JSON/CSV/XLSX."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
DEFAULT_TABLES = [
|
||||||
|
"stocks",
|
||||||
|
"price_history",
|
||||||
|
"ratios_history",
|
||||||
|
"news",
|
||||||
|
"sentiment_history",
|
||||||
|
"scrape_runs",
|
||||||
|
"scrape_progress",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_table(conn: sqlite3.Connection, table: str) -> pd.DataFrame:
|
||||||
|
return pd.read_sql_query(f"SELECT * FROM {table}", conn)
|
||||||
|
|
||||||
|
|
||||||
|
def export_json(conn: sqlite3.Connection, outdir: Path, tables: list[str]) -> int:
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for table in tables:
|
||||||
|
df = load_table(conn, table)
|
||||||
|
data = json.loads(df.to_json(orient="records", date_format="iso"))
|
||||||
|
(outdir / f"{table}.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def export_csv(conn: sqlite3.Connection, outdir: Path, tables: list[str]) -> int:
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for table in tables:
|
||||||
|
df = load_table(conn, table)
|
||||||
|
df.to_csv(outdir / f"{table}.csv", index=False)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def export_xlsx(conn: sqlite3.Connection, outfile: Path, tables: list[str]) -> int:
|
||||||
|
outfile.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with pd.ExcelWriter(outfile, engine="openpyxl") as writer:
|
||||||
|
for table in tables:
|
||||||
|
df = load_table(conn, table)
|
||||||
|
sheet = table[:31] if table else "sheet"
|
||||||
|
df.to_excel(writer, sheet_name=sheet, index=False)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Simple SQLite table exporter")
|
||||||
|
parser.add_argument("--db", required=True, help="SQLite database path")
|
||||||
|
parser.add_argument("--format", required=True, choices=["json", "csv", "xlsx"], help="Export format")
|
||||||
|
parser.add_argument("--output", "-o", required=True, help="Output path (dir for json/csv, file for xlsx)")
|
||||||
|
parser.add_argument("--tables", help="Comma-separated tables (default: common tables)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db)
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"Error: Database not found: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tables = [t.strip() for t in args.tables.split(",")] if args.tables else DEFAULT_TABLES
|
||||||
|
tables = [t for t in tables if t]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
if args.format == "json":
|
||||||
|
return export_json(conn, Path(args.output), tables)
|
||||||
|
if args.format == "csv":
|
||||||
|
return export_csv(conn, Path(args.output), tables)
|
||||||
|
return export_xlsx(conn, Path(args.output), tables)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
65
scripts/release_bundle.sh
Executable file
65
scripts/release_bundle.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
if git describe --tags --always >/dev/null 2>&1; then
|
||||||
|
VERSION="$(git describe --tags --always)"
|
||||||
|
else
|
||||||
|
VERSION="0.0.0-$(date +%Y%m%d%H%M%S)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_NAME="rubick"
|
||||||
|
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64|arm64) ARCH="arm64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
DIST_DIR="dist"
|
||||||
|
PKG_BASENAME="${APP_NAME}_${VERSION}_${OS}_${ARCH}"
|
||||||
|
PKG_DIR="${DIST_DIR}/${PKG_BASENAME}"
|
||||||
|
|
||||||
|
rm -rf "$PKG_DIR"
|
||||||
|
mkdir -p "$PKG_DIR/bin" "$PKG_DIR/scripts"
|
||||||
|
|
||||||
|
# Build binary
|
||||||
|
GOOS="$OS" GOARCH="$ARCH" go build -o "$PKG_DIR/bin/$APP_NAME" ./cmd/rubick
|
||||||
|
|
||||||
|
# Runtime Python assets
|
||||||
|
cp extractor.py "$PKG_DIR/"
|
||||||
|
cp scripts/export_dashboard.py scripts/export_history.py scripts/export_simple.py "$PKG_DIR/scripts/"
|
||||||
|
cp pyproject.toml uv.lock .env.example README.md "$PKG_DIR/"
|
||||||
|
|
||||||
|
cat > "$PKG_DIR/INSTALL.md" <<'DOC'
|
||||||
|
# Rubick Bundle Install
|
||||||
|
|
||||||
|
1. Ensure Python 3.12+ and uv are installed.
|
||||||
|
2. In this bundle directory, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --frozen
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/rubick --help
|
||||||
|
```
|
||||||
|
|
||||||
|
4. For live news queries, create `.env` with `BRAVE_API_KEY`.
|
||||||
|
DOC
|
||||||
|
|
||||||
|
# Checksums
|
||||||
|
( cd "$PKG_DIR" && shasum -a 256 bin/$APP_NAME extractor.py scripts/*.py pyproject.toml uv.lock > SHA256SUMS )
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
( cd "$DIST_DIR" && tar -czf "${PKG_BASENAME}.tar.gz" "$PKG_BASENAME" )
|
||||||
|
( cd "$DIST_DIR" && zip -qr "${PKG_BASENAME}.zip" "$PKG_BASENAME" )
|
||||||
|
|
||||||
|
echo "release_bundle=$PKG_DIR"
|
||||||
|
echo "archive_tar=${DIST_DIR}/${PKG_BASENAME}.tar.gz"
|
||||||
|
echo "archive_zip=${DIST_DIR}/${PKG_BASENAME}.zip"
|
||||||
141
tests/go/cli_test.go
Normal file
141
tests/go/cli_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package gotests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
repoRootPath string
|
||||||
|
testBinPath string
|
||||||
|
)
|
||||||
|
|
||||||
|
func repoRoot() string {
|
||||||
|
return repoRootPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
root, err := filepath.Abs("../..")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "resolve repo root: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
repoRootPath = root
|
||||||
|
|
||||||
|
binDir := filepath.Join(root, ".bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "create .bin dir: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
testBinPath = filepath.Join(binDir, "rubick-test")
|
||||||
|
build := exec.Command("go", "build", "-o", testBinPath, "./cmd/rubick")
|
||||||
|
build.Dir = root
|
||||||
|
build.Stdout = os.Stdout
|
||||||
|
build.Stderr = os.Stderr
|
||||||
|
if err := build.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "build test binary: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := m.Run()
|
||||||
|
_ = os.Remove(testBinPath)
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLI(t *testing.T, args ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(testBinPath, args...)
|
||||||
|
cmd.Dir = repoRoot()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stdout = &buf
|
||||||
|
cmd.Stderr = &buf
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return 0, buf.String()
|
||||||
|
}
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
|
return ee.ExitCode(), buf.String()
|
||||||
|
}
|
||||||
|
t.Fatalf("failed to run command %v: %v", args, err)
|
||||||
|
return -1, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLILive(t *testing.T, env map[string]string, args ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(testBinPath, args...)
|
||||||
|
cmd.Dir = repoRoot()
|
||||||
|
cmd.Env = os.Environ()
|
||||||
|
for k, v := range env {
|
||||||
|
cmd.Env = append(cmd.Env, k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stdout = &buf
|
||||||
|
cmd.Stderr = &buf
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return 0, buf.String()
|
||||||
|
}
|
||||||
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
|
return ee.ExitCode(), buf.String()
|
||||||
|
}
|
||||||
|
t.Fatalf("failed to run command %v: %v", args, err)
|
||||||
|
return -1, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLIHelpExitCodes(t *testing.T) {
|
||||||
|
cases := [][]string{
|
||||||
|
{"--help"},
|
||||||
|
{"msn", "--help"},
|
||||||
|
{"msn", "screener", "--help"},
|
||||||
|
{"msn", "fetch", "--help"},
|
||||||
|
{"msn", "fetch-all", "--help"},
|
||||||
|
{"msn", "lookup", "--help"},
|
||||||
|
{"news", "--help"},
|
||||||
|
{"export", "--help"},
|
||||||
|
{"extractor", "--help"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
code, out := runCLI(t, c...)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("expected exit 0 for %v, got %d\n%s", c, code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLIErrorExitCodes(t *testing.T) {
|
||||||
|
cases := [][]string{
|
||||||
|
{"unknown"},
|
||||||
|
{"msn"},
|
||||||
|
{"news"},
|
||||||
|
{"export"},
|
||||||
|
{"extractor"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
code, _ := runCLI(t, c...)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatalf("expected non-zero exit for %v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLILookup(t *testing.T) {
|
||||||
|
code, out := runCLI(t, "msn", "lookup", "BBCA")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("expected success, got %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "BBCA") {
|
||||||
|
t.Fatalf("expected output to contain BBCA, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
tests/go/live_e2e_test.go
Normal file
67
tests/go/live_e2e_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package gotests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isTransientNetworkErr(out string) bool {
|
||||||
|
s := strings.ToLower(out)
|
||||||
|
patterns := []string{
|
||||||
|
"no such host", "timeout", "tempor", "connection reset", "connection refused", "429",
|
||||||
|
}
|
||||||
|
for _, p := range patterns {
|
||||||
|
if strings.Contains(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveMSNScreener(t *testing.T) {
|
||||||
|
if os.Getenv("RUN_LIVE_E2E") != "1" {
|
||||||
|
t.Skip("set RUN_LIVE_E2E=1 to run live tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile := filepath.Join(repoRoot(), "output", "live_test_screener.json")
|
||||||
|
code, out := runCLILive(t, nil,
|
||||||
|
"msn", "screener",
|
||||||
|
"--region", "id",
|
||||||
|
"--filter", "large-cap",
|
||||||
|
"--limit", "1",
|
||||||
|
"--output", outFile,
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
if isTransientNetworkErr(out) {
|
||||||
|
t.Skipf("transient/live network issue: %s", out)
|
||||||
|
}
|
||||||
|
t.Fatalf("live screener failed: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveNewsQuery(t *testing.T) {
|
||||||
|
if os.Getenv("RUN_LIVE_E2E") != "1" {
|
||||||
|
t.Skip("set RUN_LIVE_E2E=1 to run live tests")
|
||||||
|
}
|
||||||
|
if os.Getenv("BRAVE_API_KEY") == "" {
|
||||||
|
t.Skip("BRAVE_API_KEY not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile := filepath.Join(repoRoot(), "output", "live_test_news.json")
|
||||||
|
code, out := runCLILive(t, nil,
|
||||||
|
"news", "IHSG",
|
||||||
|
"--from", "2026-03-04",
|
||||||
|
"--to", "2026-03-06",
|
||||||
|
"--count", "1",
|
||||||
|
"--concurrency", "1",
|
||||||
|
"--output", outFile,
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
if isTransientNetworkErr(out) {
|
||||||
|
t.Skipf("transient/live network issue: %s", out)
|
||||||
|
}
|
||||||
|
t.Fatalf("live news failed: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
tests/test_export_simple.py
Normal file
49
tests/test_export_simple.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scripts.export_simple import export_csv, export_json, export_xlsx
|
||||||
|
|
||||||
|
|
||||||
|
class ExportSimpleTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.base = Path(self.tmp.name)
|
||||||
|
self.db = self.base / "test.db"
|
||||||
|
conn = sqlite3.connect(self.db)
|
||||||
|
conn.execute("CREATE TABLE stocks (id TEXT, ticker TEXT)")
|
||||||
|
conn.execute("INSERT INTO stocks VALUES ('1','BBCA')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_export_json(self):
|
||||||
|
conn = sqlite3.connect(self.db)
|
||||||
|
out = self.base / "json"
|
||||||
|
export_json(conn, out, ["stocks"])
|
||||||
|
conn.close()
|
||||||
|
data = json.loads((out / "stocks.json").read_text())
|
||||||
|
self.assertEqual(data[0]["ticker"], "BBCA")
|
||||||
|
|
||||||
|
def test_export_csv(self):
|
||||||
|
conn = sqlite3.connect(self.db)
|
||||||
|
out = self.base / "csv"
|
||||||
|
export_csv(conn, out, ["stocks"])
|
||||||
|
conn.close()
|
||||||
|
text = (out / "stocks.csv").read_text()
|
||||||
|
self.assertIn("BBCA", text)
|
||||||
|
|
||||||
|
def test_export_xlsx(self):
|
||||||
|
conn = sqlite3.connect(self.db)
|
||||||
|
out = self.base / "out.xlsx"
|
||||||
|
export_xlsx(conn, out, ["stocks"])
|
||||||
|
conn.close()
|
||||||
|
self.assertTrue(out.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
806
uv.lock
generated
Normal file
806
uv.lock
generated
Normal file
|
|
@ -0,0 +1,806 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||||
|
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
|
||||||
|
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
|
"python_full_version < '3.14' and sys_platform == 'win32'",
|
||||||
|
"python_full_version < '3.14' and sys_platform == 'emscripten'",
|
||||||
|
"python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.14.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "et-xmlfile"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "feedparser"
|
||||||
|
version = "6.0.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "sgmllib3k" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.21.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/73/71/74364ff065ca78914d8bd90b312fe78ddc5e11372d38bc9cb7104f887ce1/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708", size = 31486, upload-time = "2026-02-13T01:27:15.223Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/73/3a18f1e1276810e81477c431009b55eeccebbd7301d28a350b77aacf3c33/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560", size = 21479, upload-time = "2026-02-13T01:27:13.611Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "joblib"
|
||||||
|
version = "1.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lxml"
|
||||||
|
version = "6.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
html-clean = [
|
||||||
|
{ name = "lxml-html-clean" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lxml-html-clean"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "lxml" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d9/cb/c9c5bb2a9c47292e236a808dd233a03531f53b626f36259dcd32b49c76da/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c", size = 21498, upload-time = "2025-10-02T20:49:24.895Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/4a/63a9540e3ca73709f4200564a737d63a4c8c9c4dd032bab8535f507c190a/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e", size = 14177, upload-time = "2025-10-02T20:49:23.749Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "newspaper4k"
|
||||||
|
version = "0.9.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "brotli" },
|
||||||
|
{ name = "feedparser" },
|
||||||
|
{ name = "lxml", extra = ["html-clean"] },
|
||||||
|
{ name = "nltk" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "tldextract" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b7/cc/cf743a3d06b10907cec76a675fe0857445907ded0e64ec4624483c1467ce/newspaper4k-0.9.4.1.tar.gz", hash = "sha256:5b1a92dfb04d6d379f9484fad4ad44741deb9ac3d55a6c178badf2a0d4bba903", size = 3971874, upload-time = "2025-11-18T06:08:56.543Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/e8/7e6c6a6e626fec2ec25f7b69fa0c446d1ca8a7fad121b61f2672e5779b76/newspaper4k-0.9.4.1-py3-none-any.whl", hash = "sha256:fab18fdb0637da0ea2452e18c5986c4af2263ba3016ff684f1c522f696bd39bd", size = 306153, upload-time = "2025-11-18T06:08:54.9Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nltk"
|
||||||
|
version = "3.9.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "joblib" },
|
||||||
|
{ name = "regex" },
|
||||||
|
{ name = "tqdm" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/76/3a5e4312c19a028770f86fd7c058cf9f4ec4321c6cf7526bab998a5b683c/nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419", size = 2887629, upload-time = "2025-10-01T07:19:23.764Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a", size = 1513404, upload-time = "2025-10-01T07:19:21.648Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openpyxl"
|
||||||
|
version = "3.1.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "et-xmlfile" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pandas"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/38/db33686f4b5fa64d7af40d96361f6a4615b8c6c8f1b3d334eee46ae6160e/pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", size = 10334013, upload-time = "2026-01-21T15:50:34.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/7b/9254310594e9774906bacdd4e732415e1f86ab7dbb4b377ef9ede58cd8ec/pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", size = 9874154, upload-time = "2026-01-21T15:50:36.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/d4/726c5a67a13bc66643e66d2e9ff115cead482a44fc56991d0c4014f15aaf/pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", size = 10384433, upload-time = "2026-01-21T15:50:39.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/2e/9211f09bedb04f9832122942de8b051804b31a39cfbad199a819bb88d9f3/pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", size = 10864519, upload-time = "2026-01-21T15:50:41.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/8d/50858522cdc46ac88b9afdc3015e298959a70a08cd21e008a44e9520180c/pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", size = 11394124, upload-time = "2026-01-21T15:50:43.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/3f/83b2577db02503cd93d8e95b0f794ad9d4be0ba7cb6c8bcdcac964a34a42/pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", size = 11920444, upload-time = "2026-01-21T15:50:45.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/2d/4f8a2f192ed12c90a0aab47f5557ece0e56b0370c49de9454a09de7381b2/pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", size = 9730970, upload-time = "2026-01-21T15:50:47.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/64/ff571be435cf1e643ca98d0945d76732c0b4e9c37191a89c8550b105eed1/pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", size = 9041950, upload-time = "2026-01-21T15:50:50.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/fa/7f0ac4ca8877c57537aaff2a842f8760e630d8e824b730eb2e859ffe96ca/pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", size = 10307129, upload-time = "2026-01-21T15:50:52.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/11/28a221815dcea4c0c9414dfc845e34a84a6a7dabc6da3194498ed5ba4361/pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", size = 9850201, upload-time = "2026-01-21T15:50:54.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/da/53bbc8c5363b7e5bd10f9ae59ab250fc7a382ea6ba08e4d06d8694370354/pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", size = 10354031, upload-time = "2026-01-21T15:50:57.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/a3/51e02ebc2a14974170d51e2410dfdab58870ea9bcd37cda15bd553d24dc4/pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", size = 10861165, upload-time = "2026-01-21T15:50:59.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/fe/05a51e3cac11d161472b8297bd41723ea98013384dd6d76d115ce3482f9b/pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", size = 11359359, upload-time = "2026-01-21T15:51:02.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/56/ba620583225f9b85a4d3e69c01df3e3870659cc525f67929b60e9f21dcd1/pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", size = 11912907, upload-time = "2026-01-21T15:51:05.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/8c/c6638d9f67e45e07656b3826405c5cc5f57f6fd07c8b2572ade328c86e22/pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", size = 9732138, upload-time = "2026-01-21T15:51:07.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/bf/bd1335c3bf1770b6d8fed2799993b11c4971af93bb1b729b9ebbc02ca2ec/pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", size = 9033568, upload-time = "2026-01-21T15:51:09.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/c6/f5e2171914d5e29b9171d495344097d54e3ffe41d2d85d8115baba4dc483/pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", size = 10741936, upload-time = "2026-01-21T15:51:11.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/88/9a0164f99510a1acb9f548691f022c756c2314aad0d8330a24616c14c462/pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", size = 10393884, upload-time = "2026-01-21T15:51:14.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/53/b34d78084d88d8ae2b848591229da8826d1e65aacf00b3abe34023467648/pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", size = 10310740, upload-time = "2026-01-21T15:51:16.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/d3/bee792e7c3d6930b74468d990604325701412e55d7aaf47460a22311d1a5/pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", size = 10700014, upload-time = "2026-01-21T15:51:18.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/db/2570bc40fb13aaed1cbc3fbd725c3a60ee162477982123c3adc8971e7ac1/pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", size = 11323737, upload-time = "2026-01-21T15:51:20.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/2e/297ac7f21c8181b62a4cccebad0a70caf679adf3ae5e83cb676194c8acc3/pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", size = 11771558, upload-time = "2026-01-21T15:51:22.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/46/e1c6876d71c14332be70239acce9ad435975a80541086e5ffba2f249bcf6/pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", size = 10473771, upload-time = "2026-01-21T15:51:25.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/db/0270ad9d13c344b7a36fa77f5f8344a46501abf413803e885d22864d10bf/pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", size = 10312075, upload-time = "2026-01-21T15:51:28.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/9f/c176f5e9717f7c91becfe0f55a52ae445d3f7326b4a2cf355978c51b7913/pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", size = 9900213, upload-time = "2026-01-21T15:51:30.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/e7/63ad4cc10b257b143e0a5ebb04304ad806b4e1a61c5da25f55896d2ca0f4/pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", size = 10428768, upload-time = "2026-01-21T15:51:33.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/0e/4e4c2d8210f20149fd2248ef3fff26623604922bd564d915f935a06dd63d/pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d", size = 10882954, upload-time = "2026-01-21T15:51:35.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/60/c9de8ac906ba1f4d2250f8a951abe5135b404227a55858a75ad26f84db47/pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", size = 11430293, upload-time = "2026-01-21T15:51:37.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/69/806e6637c70920e5787a6d6896fd707f8134c2c55cd761e7249a97b7dc5a/pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", size = 11952452, upload-time = "2026-01-21T15:51:39.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/de/918621e46af55164c400ab0ef389c9d969ab85a43d59ad1207d4ddbe30a5/pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", size = 9851081, upload-time = "2026-01-21T15:51:41.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/a1/3562a18dd0bd8c73344bfa26ff90c53c72f827df119d6d6b1dacc84d13e3/pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", size = 9174610, upload-time = "2026-01-21T15:51:44.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/26/430d91257eaf366f1737d7a1c158677caaf6267f338ec74e3a1ec444111c/pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", size = 10761999, upload-time = "2026-01-21T15:51:46.899Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/1a/954eb47736c2b7f7fe6a9d56b0cb6987773c00faa3c6451a43db4beb3254/pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", size = 10410279, upload-time = "2026-01-21T15:51:48.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/fc/b96f3a5a28b250cd1b366eb0108df2501c0f38314a00847242abab71bb3a/pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", size = 10330198, upload-time = "2026-01-21T15:51:51.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/b3/d0e2952f103b4fbef1ef22d0c2e314e74fc9064b51cee30890b5e3286ee6/pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", size = 10728513, upload-time = "2026-01-21T15:51:53.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/81/832894f286df828993dc5fd61c63b231b0fb73377e99f6c6c369174cf97e/pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", size = 11345550, upload-time = "2026-01-21T15:51:55.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/a0/ed160a00fb4f37d806406bc0a79a8b62fe67f29d00950f8d16203ff3409b/pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", size = 11799386, upload-time = "2026-01-21T15:51:57.457Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c8/2ac00d7255252c5e3cf61b35ca92ca25704b0188f7454ca4aec08a33cece/pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", size = 10873041, upload-time = "2026-01-21T15:52:00.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003, upload-time = "2026-01-21T15:52:02.281Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "2026.1.15"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests-file"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sgmllib3k"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rubick"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "lxml-html-clean" },
|
||||||
|
{ name = "newspaper4k" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
|
{ name = "pandas" },
|
||||||
|
{ name = "xlsxwriter" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "lxml-html-clean", specifier = ">=0.4.3" },
|
||||||
|
{ name = "newspaper4k", specifier = ">=0.9.4.1" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
|
{ name = "pandas", specifier = ">=3.0.0" },
|
||||||
|
{ name = "xlsxwriter", specifier = ">=3.2.9" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tldextract"
|
||||||
|
version = "5.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "requests-file" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/7b/644fbbb49564a6cb124a8582013315a41148dba2f72209bba14a84242bf0/tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb", size = 126105, upload-time = "2025-12-28T23:58:05.532Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/42/0e49d6d0aac449ca71952ec5bae764af009754fcb2e76a5cc097543747b3/tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9", size = 105886, upload-time = "2025-12-28T23:58:04.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tqdm"
|
||||||
|
version = "4.67.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2025.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xlsxwriter"
|
||||||
|
version = "3.2.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue