twrate 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twrate-0.5.1/.github/dependabot.yml +11 -0
- twrate-0.5.1/.github/workflows/bump-version.yml +38 -0
- twrate-0.5.1/.github/workflows/cron.yml +25 -0
- twrate-0.5.1/.github/workflows/docker.yml +46 -0
- twrate-0.5.1/.github/workflows/python.yml +30 -0
- twrate-0.5.1/.github/workflows/release.yml +56 -0
- twrate-0.5.1/.gitignore +135 -0
- twrate-0.5.1/.pre-commit-config.yaml +24 -0
- twrate-0.5.1/AGENTS.md +191 -0
- twrate-0.5.1/Dockerfile +28 -0
- twrate-0.5.1/LICENSE +21 -0
- twrate-0.5.1/Makefile +19 -0
- twrate-0.5.1/PKG-INFO +197 -0
- twrate-0.5.1/README.md +181 -0
- twrate-0.5.1/example.py +16 -0
- twrate-0.5.1/pyproject.toml +74 -0
- twrate-0.5.1/scripts/db.py +108 -0
- twrate-0.5.1/skills/using-twrate-cli/SKILL.md +38 -0
- twrate-0.5.1/src/twrate/__init__.py +13 -0
- twrate-0.5.1/src/twrate/cli.py +133 -0
- twrate-0.5.1/src/twrate/fetcher.py +51 -0
- twrate-0.5.1/src/twrate/fetchers/__init__.py +0 -0
- twrate-0.5.1/src/twrate/fetchers/_ssl.py +20 -0
- twrate-0.5.1/src/twrate/fetchers/bot.py +64 -0
- twrate-0.5.1/src/twrate/fetchers/cathay.py +118 -0
- twrate-0.5.1/src/twrate/fetchers/cooperative.py +108 -0
- twrate-0.5.1/src/twrate/fetchers/dbs.py +79 -0
- twrate-0.5.1/src/twrate/fetchers/esun.py +77 -0
- twrate-0.5.1/src/twrate/fetchers/firstbank.py +97 -0
- twrate-0.5.1/src/twrate/fetchers/fubon.py +72 -0
- twrate-0.5.1/src/twrate/fetchers/hsbc.py +64 -0
- twrate-0.5.1/src/twrate/fetchers/kgi.py +79 -0
- twrate-0.5.1/src/twrate/fetchers/landbank.py +92 -0
- twrate-0.5.1/src/twrate/fetchers/line.py +40 -0
- twrate-0.5.1/src/twrate/fetchers/megabank.py +76 -0
- twrate-0.5.1/src/twrate/fetchers/nextbank.py +66 -0
- twrate-0.5.1/src/twrate/fetchers/sinopac.py +132 -0
- twrate-0.5.1/src/twrate/fetchers/taichung.py +81 -0
- twrate-0.5.1/src/twrate/fetchers/taishin.py +138 -0
- twrate-0.5.1/src/twrate/fetchers/yuanta.py +88 -0
- twrate-0.5.1/src/twrate/py.typed +0 -0
- twrate-0.5.1/src/twrate/types.py +97 -0
- twrate-0.5.1/tests/__init__.py +0 -0
- twrate-0.5.1/tests/data/dbs.json +120 -0
- twrate-0.5.1/tests/data/esunbank.json +1229 -0
- twrate-0.5.1/tests/data/sinopac.json +175 -0
- twrate-0.5.1/tests/test_fetcher.py +35 -0
- twrate-0.5.1/uv.lock +760 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Bump Version
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
bump-type:
|
|
7
|
+
description: "Bump type"
|
|
8
|
+
required: true
|
|
9
|
+
default: "patch"
|
|
10
|
+
type: choice
|
|
11
|
+
options:
|
|
12
|
+
- major
|
|
13
|
+
- minor
|
|
14
|
+
- patch
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: write
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
bump-version:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- name: Checkout the code
|
|
24
|
+
uses: actions/checkout@v7
|
|
25
|
+
- uses: astral-sh/setup-uv@v7
|
|
26
|
+
- name: Bump version
|
|
27
|
+
id: bump
|
|
28
|
+
uses: callowayproject/bump-my-version@master
|
|
29
|
+
env:
|
|
30
|
+
BUMPVERSION_TAG: "true"
|
|
31
|
+
with:
|
|
32
|
+
args: ${{ inputs.bump-type }}
|
|
33
|
+
# Use a Personal Access Token (PAT) to ensure the publish workflow is triggered on tag push
|
|
34
|
+
github-token: ${{ secrets.PAT_TOKEN }}
|
|
35
|
+
- name: Check
|
|
36
|
+
if: steps.bump.outputs.bumped == 'true'
|
|
37
|
+
run: |
|
|
38
|
+
echo "Version was bumped from ${{ steps.bump.outputs.previous-version }} to ${{ steps.bump.outputs.current-version }}!"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Cron
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
# schedule:
|
|
6
|
+
# - cron: "0 0 * * *"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
cron:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- uses: astral-sh/setup-uv@v7
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Run
|
|
20
|
+
run: uv run python scripts/db.py
|
|
21
|
+
env:
|
|
22
|
+
INFLUXDB_URL: ${{ secrets.INFLUXDB_URL }}
|
|
23
|
+
INFLUXDB_TOKEN: ${{ secrets.INFLUXDB_TOKEN }}
|
|
24
|
+
INFLUXDB_ORG: ${{ secrets.INFLUXDB_ORG }}
|
|
25
|
+
INFLUXDB_BUCKET: ${{ secrets.INFLUXDB_BUCKET }}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Docker
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
# branches:
|
|
7
|
+
# - main
|
|
8
|
+
tags:
|
|
9
|
+
- "v*.*.*"
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
docker:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
# https://docs.docker.com/build/ci/github-actions/manage-tags-labels/
|
|
16
|
+
- name: Docker meta
|
|
17
|
+
id: meta
|
|
18
|
+
uses: docker/metadata-action@v6
|
|
19
|
+
with:
|
|
20
|
+
images: |
|
|
21
|
+
${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
|
22
|
+
flavor: |
|
|
23
|
+
latest=auto
|
|
24
|
+
tags: |
|
|
25
|
+
type=schedule
|
|
26
|
+
type=ref,event=branch
|
|
27
|
+
type=ref,event=pr
|
|
28
|
+
type=semver,pattern={{version}}
|
|
29
|
+
type=semver,pattern={{major}}.{{minor}}
|
|
30
|
+
type=semver,pattern={{major}}
|
|
31
|
+
type=sha
|
|
32
|
+
- name: Set up QEMU
|
|
33
|
+
uses: docker/setup-qemu-action@v4
|
|
34
|
+
- name: Set up Docker Buildx
|
|
35
|
+
uses: docker/setup-buildx-action@v4
|
|
36
|
+
- name: Login to Docker Hub
|
|
37
|
+
uses: docker/login-action@v4
|
|
38
|
+
with:
|
|
39
|
+
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
40
|
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
41
|
+
- name: Build and push
|
|
42
|
+
uses: docker/build-push-action@v7
|
|
43
|
+
with:
|
|
44
|
+
push: ${{ github.event_name != 'pull_request' }}
|
|
45
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
46
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Python
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
pull_request:
|
|
9
|
+
branches:
|
|
10
|
+
- main
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
python:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.12"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
- uses: astral-sh/setup-uv@v7
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: uv sync
|
|
25
|
+
- name: Lint
|
|
26
|
+
run: uv run ruff check .
|
|
27
|
+
- name: Type check
|
|
28
|
+
run: uv run ty check .
|
|
29
|
+
- name: Test
|
|
30
|
+
run: uv run pytest -v -s --cov=src --cov-report=xml tests
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*.*.*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- uses: astral-sh/setup-uv@v7
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- name: Build distributions
|
|
20
|
+
run: uv build
|
|
21
|
+
- name: Upload distributions
|
|
22
|
+
uses: actions/upload-artifact@v7
|
|
23
|
+
with:
|
|
24
|
+
name: dist
|
|
25
|
+
path: dist/*
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
needs: build
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/download-artifact@v8
|
|
32
|
+
with:
|
|
33
|
+
name: dist
|
|
34
|
+
path: dist
|
|
35
|
+
- uses: astral-sh/setup-uv@v7
|
|
36
|
+
- name: Publish to PyPI
|
|
37
|
+
env:
|
|
38
|
+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
39
|
+
run: uv publish
|
|
40
|
+
|
|
41
|
+
github-release:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
needs: publish
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/download-artifact@v8
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist
|
|
49
|
+
- name: Create GitHub release
|
|
50
|
+
env:
|
|
51
|
+
GH_TOKEN: ${{ github.token }}
|
|
52
|
+
run: |
|
|
53
|
+
gh release create "${{ github.ref_name }}" dist/* \
|
|
54
|
+
--repo "${{ github.repository }}" \
|
|
55
|
+
--title "${{ github.ref_name }}" \
|
|
56
|
+
--generate-notes
|
twrate-0.5.1/.gitignore
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
pip-wheel-metadata/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
target/
|
|
76
|
+
|
|
77
|
+
# Jupyter Notebook
|
|
78
|
+
.ipynb_checkpoints
|
|
79
|
+
|
|
80
|
+
# IPython
|
|
81
|
+
profile_default/
|
|
82
|
+
ipython_config.py
|
|
83
|
+
|
|
84
|
+
# pyenv
|
|
85
|
+
.python-version
|
|
86
|
+
|
|
87
|
+
# pipenv
|
|
88
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
89
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
90
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
91
|
+
# install all needed dependencies.
|
|
92
|
+
#Pipfile.lock
|
|
93
|
+
|
|
94
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
95
|
+
__pypackages__/
|
|
96
|
+
|
|
97
|
+
# Celery stuff
|
|
98
|
+
celerybeat-schedule
|
|
99
|
+
celerybeat.pid
|
|
100
|
+
|
|
101
|
+
# SageMath parsed files
|
|
102
|
+
*.sage.py
|
|
103
|
+
|
|
104
|
+
# Environments
|
|
105
|
+
.env
|
|
106
|
+
.venv
|
|
107
|
+
env/
|
|
108
|
+
venv/
|
|
109
|
+
ENV/
|
|
110
|
+
env.bak/
|
|
111
|
+
venv.bak/
|
|
112
|
+
|
|
113
|
+
# Spyder project settings
|
|
114
|
+
.spyderproject
|
|
115
|
+
.spyproject
|
|
116
|
+
|
|
117
|
+
# Rope project settings
|
|
118
|
+
.ropeproject
|
|
119
|
+
|
|
120
|
+
# mkdocs documentation
|
|
121
|
+
/site
|
|
122
|
+
|
|
123
|
+
# mypy
|
|
124
|
+
.mypy_cache/
|
|
125
|
+
.dmypy.json
|
|
126
|
+
dmypy.json
|
|
127
|
+
|
|
128
|
+
# Pyre type checker
|
|
129
|
+
.pyre/
|
|
130
|
+
|
|
131
|
+
# ruff
|
|
132
|
+
.ruff_cache/
|
|
133
|
+
|
|
134
|
+
# VS Code
|
|
135
|
+
.vscode/
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v6.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: check-yaml
|
|
6
|
+
- id: end-of-file-fixer
|
|
7
|
+
- id: trailing-whitespace
|
|
8
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
9
|
+
rev: v0.14.10
|
|
10
|
+
hooks:
|
|
11
|
+
- id: ruff
|
|
12
|
+
args: [--fix]
|
|
13
|
+
- id: ruff-format
|
|
14
|
+
- repo: https://github.com/astral-sh/uv-pre-commit
|
|
15
|
+
rev: 0.9.18
|
|
16
|
+
hooks:
|
|
17
|
+
- id: uv-lock
|
|
18
|
+
- repo: local
|
|
19
|
+
hooks:
|
|
20
|
+
- id: ty-check
|
|
21
|
+
name: ty-check
|
|
22
|
+
entry: ty check .
|
|
23
|
+
language: python
|
|
24
|
+
pass_filenames: false
|
twrate-0.5.1/AGENTS.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Agents Architecture
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`twrate` uses a fetcher-based architecture: each async fetcher pulls exchange rates from one Taiwanese bank and returns a shared `Rate` model. Fetchers run concurrently for fast fan-out and easy comparison across sources.
|
|
6
|
+
|
|
7
|
+
## Workflow
|
|
8
|
+
|
|
9
|
+
Use `uv` for all Python operations:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Fetch rates
|
|
13
|
+
uv run twrate USD
|
|
14
|
+
|
|
15
|
+
# Tests
|
|
16
|
+
uv run pytest
|
|
17
|
+
|
|
18
|
+
# Dependencies
|
|
19
|
+
uv sync
|
|
20
|
+
uv add <package-name>
|
|
21
|
+
|
|
22
|
+
# Run scripts / one-liners (always use uv)
|
|
23
|
+
uv run python script.py
|
|
24
|
+
uv run python -c "import httpx; print(httpx.__version__)"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Why `uv`:
|
|
28
|
+
- Fast dependency management
|
|
29
|
+
- Managed virtualenv
|
|
30
|
+
- Reproducible via `uv.lock`
|
|
31
|
+
|
|
32
|
+
Important: Always use `uv run python` instead of `python` or `python3`.
|
|
33
|
+
|
|
34
|
+
## Core Models
|
|
35
|
+
|
|
36
|
+
### Rate (`types.py`)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
class Rate(BaseModel):
|
|
40
|
+
exchange: Exchange # Bank identifier
|
|
41
|
+
source: str # Source currency code
|
|
42
|
+
target: str # Target currency (usually "TWD")
|
|
43
|
+
spot_buy: float | None
|
|
44
|
+
spot_sell: float | None
|
|
45
|
+
cash_buy: float | None
|
|
46
|
+
cash_sell: float | None
|
|
47
|
+
fetched_at: datetime
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Computed properties:
|
|
51
|
+
- `spot_mid`, `cash_mid`
|
|
52
|
+
- `spot_spread`, `cash_spread`
|
|
53
|
+
- `symbol`
|
|
54
|
+
|
|
55
|
+
### Exchange (`types.py`)
|
|
56
|
+
|
|
57
|
+
Supported banks:
|
|
58
|
+
- `DBS`, `SINOPAC`, `BOT`, `ESUN`, `LINE`, `HSBC`, `NEXT`, `KGI`, `CATHAY`
|
|
59
|
+
|
|
60
|
+
## Fetcher Contract
|
|
61
|
+
|
|
62
|
+
Each fetcher exposes `async def fetch_*_rates() -> list[Rate]` and follows:
|
|
63
|
+
1. Single bank responsibility
|
|
64
|
+
2. Consistent return type (`list[Rate]`)
|
|
65
|
+
3. HTTP errors via `raise_for_status()`
|
|
66
|
+
4. Pydantic for validation
|
|
67
|
+
5. Async first (`httpx.AsyncClient`, `asyncio.gather()`)
|
|
68
|
+
|
|
69
|
+
## Fetchers by Bank
|
|
70
|
+
|
|
71
|
+
- **BOT** (`bot.py`) — Text API `https://rate.bot.com.tw/xrt/fltxt/0/day`; UTF-8-SIG parsing; fixed columns; spot + cash.
|
|
72
|
+
- **DBS** (`dbs.py`) — JSON API `https://www.dbs.com.tw/tw-rates-api/v1/api/twrates/latestForexRates`; nested Pydantic models; cash often missing.
|
|
73
|
+
- **ESUN** (`esun.py`) — JSON POST `https://www.esunbank.com/api/client/ExchangeRate/LastRateInfo`; `/Date(ts)/` parsing; dash as missing.
|
|
74
|
+
- **LINE** (`line.py`) — HTML `https://www.linebank.com.tw/board-rate/exchange-rate`; USD/TWD only; spot only.
|
|
75
|
+
- **SINOPAC** (`sinopac.py`) — Two JSON endpoints; merge spot + cash via `merge_rates()`.
|
|
76
|
+
- **HSBC** (`hsbc.py`) — HTML `https://www.hsbc.com.tw/currency-rates/`; regex currency codes; dash as missing.
|
|
77
|
+
- **NEXT** (`nextbank.py`) — HTML `https://www.nextbank.com.tw/exchange-rates`; strict ISO currency validation.
|
|
78
|
+
- **KGI** (`kgi.py`) — HTML `https://www.kgibank.com.tw/zh-tw/personal/interest-rate/fx`; desktop/mobile tables; numeric-or-dash guard.
|
|
79
|
+
- **CATHAY** (`cathay.py`) — HTML `https://www.cathaybk.com.tw/cathaybk/personal/product/deposit/currency-billboard/`; clean CSS structure; spot + cash.
|
|
80
|
+
|
|
81
|
+
## Dispatcher
|
|
82
|
+
|
|
83
|
+
`fetch_rates()` in `fetcher.py` routes to the correct fetcher by `Exchange`.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
async def fetch_rates(exchange: Exchange) -> list[Rate]:
|
|
87
|
+
match exchange:
|
|
88
|
+
case Exchange.SINOPAC:
|
|
89
|
+
return await fetch_sinopac_rates()
|
|
90
|
+
case Exchange.ESUN:
|
|
91
|
+
return await fetch_esun_rates()
|
|
92
|
+
case Exchange.LINE:
|
|
93
|
+
return await fetch_line_rates()
|
|
94
|
+
case Exchange.BOT:
|
|
95
|
+
return await fetch_bot_rates()
|
|
96
|
+
case Exchange.DBS:
|
|
97
|
+
return await fetch_dbs_rates()
|
|
98
|
+
case Exchange.HSBC:
|
|
99
|
+
return await fetch_hsbc_rates()
|
|
100
|
+
case Exchange.NEXT:
|
|
101
|
+
return await fetch_nextbank_rates()
|
|
102
|
+
case Exchange.KGI:
|
|
103
|
+
return await fetch_kgi_rates()
|
|
104
|
+
case Exchange.CATHAY:
|
|
105
|
+
return await fetch_cathay_rates()
|
|
106
|
+
case _:
|
|
107
|
+
raise ValueError(f"Unsupported exchange: {exchange}")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Adding a Fetcher
|
|
111
|
+
|
|
112
|
+
1. Create a module in `src/twrate/fetchers/`.
|
|
113
|
+
2. Add an `Exchange` enum value in `types.py`.
|
|
114
|
+
3. Implement `async def fetch_*_rates()` returning `list[Rate]`.
|
|
115
|
+
4. Wire it in `fetcher.py`.
|
|
116
|
+
5. Add tests in `tests/`.
|
|
117
|
+
|
|
118
|
+
Template:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import httpx
|
|
122
|
+
from ..types import Exchange, Rate
|
|
123
|
+
|
|
124
|
+
async def fetch_example_rates() -> list[Rate]:
|
|
125
|
+
url = "https://example.bank.com/api/rates"
|
|
126
|
+
|
|
127
|
+
async with httpx.AsyncClient() as client:
|
|
128
|
+
resp = await client.get(url)
|
|
129
|
+
resp.raise_for_status()
|
|
130
|
+
|
|
131
|
+
rates = []
|
|
132
|
+
for item in resp.json():
|
|
133
|
+
rates.append(
|
|
134
|
+
Rate(
|
|
135
|
+
exchange=Exchange.EXAMPLE,
|
|
136
|
+
source=item["currency"],
|
|
137
|
+
target="TWD",
|
|
138
|
+
spot_buy=item["buy"],
|
|
139
|
+
spot_sell=item["sell"],
|
|
140
|
+
cash_buy=item.get("cash_buy"),
|
|
141
|
+
cash_sell=item.get("cash_sell"),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return rates
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Error Handling
|
|
149
|
+
|
|
150
|
+
- HTTP errors bubble up (`raise_for_status()`).
|
|
151
|
+
- Pydantic validation errors bubble up.
|
|
152
|
+
- Missing values use `None` (or parsed dash placeholders).
|
|
153
|
+
|
|
154
|
+
## Testing
|
|
155
|
+
|
|
156
|
+
- Unit tests: `tests/test_fetcher.py`
|
|
157
|
+
- Sample data: `tests/data/`
|
|
158
|
+
- Integration tests may hit real endpoints.
|
|
159
|
+
|
|
160
|
+
## Dependencies
|
|
161
|
+
|
|
162
|
+
- `httpx`
|
|
163
|
+
- `pydantic`
|
|
164
|
+
- `beautifulsoup4`
|
|
165
|
+
- `loguru`
|
|
166
|
+
|
|
167
|
+
## Troubleshooting: JS-rendered Pages (Taishin Case)
|
|
168
|
+
|
|
169
|
+
Summary: Taishin’s rate table is client-side rendered, so `httpx + BeautifulSoup` returns no data. Playwright can render it but adds heavy deps.
|
|
170
|
+
|
|
171
|
+
Verification:
|
|
172
|
+
```bash
|
|
173
|
+
uv run python -c "\
|
|
174
|
+
import httpx; from bs4 import BeautifulSoup; \
|
|
175
|
+
resp = httpx.get('https://www.taishinbank.com.tw/TSB/personal/deposit/lookup/realtime/'); \
|
|
176
|
+
soup = BeautifulSoup(resp.text, 'html.parser'); \
|
|
177
|
+
print(f'Tables: {len(soup.find_all(\"table\"))}'); \
|
|
178
|
+
print('USD in response:', '美元USD' in resp.text)\
|
|
179
|
+
"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Decision: keep fetcher lightweight and accept no data for JS-only sites unless the source is critical.
|
|
183
|
+
|
|
184
|
+
## Future Enhancements
|
|
185
|
+
|
|
186
|
+
- Retry with backoff
|
|
187
|
+
- Caching with TTL
|
|
188
|
+
- Rate limiting per bank
|
|
189
|
+
- Health checks
|
|
190
|
+
- Historical rates
|
|
191
|
+
- Optional Playwright for JS-heavy sites
|
twrate-0.5.1/Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# https://docs.astral.sh/uv/guides/integration/docker/#non-editable-installs
|
|
2
|
+
ARG PYTHON_VERSION=3.12
|
|
3
|
+
ARG DEBIAN_VERSION=bookworm
|
|
4
|
+
FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-${DEBIAN_VERSION}-slim AS uv
|
|
5
|
+
|
|
6
|
+
WORKDIR /app
|
|
7
|
+
|
|
8
|
+
ENV UV_COMPILE_BYTECODE=1
|
|
9
|
+
ENV UV_LINK_MODE=copy
|
|
10
|
+
|
|
11
|
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
12
|
+
--mount=type=bind,source=uv.lock,target=uv.lock \
|
|
13
|
+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
|
14
|
+
uv sync --frozen --no-install-project --no-dev --no-editable
|
|
15
|
+
|
|
16
|
+
ADD . /app
|
|
17
|
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
18
|
+
uv sync --frozen --no-dev --no-editable
|
|
19
|
+
|
|
20
|
+
FROM python:${PYTHON_VERSION}-slim-${DEBIAN_VERSION}
|
|
21
|
+
|
|
22
|
+
WORKDIR /app
|
|
23
|
+
|
|
24
|
+
COPY --from=uv --chown=app:app /app/.venv /app/.venv
|
|
25
|
+
|
|
26
|
+
ENV PATH="/app/.venv/bin:$PATH"
|
|
27
|
+
|
|
28
|
+
ENTRYPOINT ["twrate"]
|
twrate-0.5.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 なるみ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
twrate-0.5.1/Makefile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
format:
|
|
2
|
+
uv run ruff format .
|
|
3
|
+
|
|
4
|
+
lint:
|
|
5
|
+
uv run ruff check --fix .
|
|
6
|
+
|
|
7
|
+
type:
|
|
8
|
+
uv run mypy --install-types --non-interactive .
|
|
9
|
+
|
|
10
|
+
test:
|
|
11
|
+
uv run pytest -v -s --cov=src tests
|
|
12
|
+
|
|
13
|
+
publish:
|
|
14
|
+
uv build -f wheel
|
|
15
|
+
uv publish
|
|
16
|
+
|
|
17
|
+
all: format lint type test
|
|
18
|
+
|
|
19
|
+
.PHONY: lint test publish
|