perexchange 1.0.0__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.
- perexchange-1.0.0/MANIFEST.in +2 -0
- perexchange-1.0.0/PKG-INFO +110 -0
- perexchange-1.0.0/perexchange/__init__.py +16 -0
- perexchange-1.0.0/perexchange/core.py +70 -0
- perexchange-1.0.0/perexchange/models.py +21 -0
- perexchange-1.0.0/perexchange/scrapers/__init__.py +68 -0
- perexchange-1.0.0/perexchange/scrapers/base.py +87 -0
- perexchange-1.0.0/perexchange/scrapers/cambiafx.py +54 -0
- perexchange-1.0.0/perexchange/scrapers/cambioseguro.py +73 -0
- perexchange-1.0.0/perexchange/scrapers/chapacambio.py +61 -0
- perexchange-1.0.0/perexchange/scrapers/cuantoestaeldolar.py +94 -0
- perexchange-1.0.0/perexchange/scrapers/dollarhouse.py +85 -0
- perexchange-1.0.0/perexchange/scrapers/instakash.py +75 -0
- perexchange-1.0.0/perexchange/scrapers/srcambio.py +42 -0
- perexchange-1.0.0/perexchange/scrapers/tkambio.py +86 -0
- perexchange-1.0.0/perexchange/scrapers/tucambista.py +54 -0
- perexchange-1.0.0/perexchange/scrapers/westernunion.py +89 -0
- perexchange-1.0.0/perexchange/scrapers/yanki.py +60 -0
- perexchange-1.0.0/perexchange.egg-info/PKG-INFO +110 -0
- perexchange-1.0.0/perexchange.egg-info/SOURCES.txt +24 -0
- perexchange-1.0.0/perexchange.egg-info/dependency_links.txt +1 -0
- perexchange-1.0.0/perexchange.egg-info/requires.txt +3 -0
- perexchange-1.0.0/perexchange.egg-info/top_level.txt +2 -0
- perexchange-1.0.0/pyproject.toml +22 -0
- perexchange-1.0.0/readme.md +98 -0
- perexchange-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perexchange
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Library for fetching Peruvian exchange rates
|
|
5
|
+
Author-email: David Duran <dadch1404@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: beautifulsoup4>=4.14.2
|
|
10
|
+
Requires-Dist: httpx[http2]>=0.27.2
|
|
11
|
+
Requires-Dist: lxml>=6.0.2
|
|
12
|
+
|
|
13
|
+
# [pkg]: perexchange
|
|
14
|
+
|
|
15
|
+
Core library for fetching PEN-USD exchange rates from Peruvian exchange houses. To install
|
|
16
|
+
it, run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install perexchange
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The library provides a single async function that fetches rates from multiple sources
|
|
23
|
+
concurrently:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import asyncio
|
|
27
|
+
import perexchange as px
|
|
28
|
+
|
|
29
|
+
async def main():
|
|
30
|
+
rates = await px.fetch_rates()
|
|
31
|
+
best = min(rates, key=lambda r: r.buy_price)
|
|
32
|
+
print(f"{best.name}: S/{best.buy_price}")
|
|
33
|
+
|
|
34
|
+
asyncio.run(main())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Fetching rates
|
|
38
|
+
|
|
39
|
+
Call `fetch_rates()` to retrieve current rates. By default it queries all available
|
|
40
|
+
sources: cambioseguro, cuantoestaeldolar, tkambio, tucambista, and westernunion. The
|
|
41
|
+
function returns a list of `ExchangeRate` objects.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
rates = await px.fetch_rates()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
You can fetch from specific sources by passing a list of house names. This is useful when
|
|
48
|
+
you only need rates from certain providers or want to reduce latency:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
rates = await px.fetch_rates(houses=["tkambio", "tucambista"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The function accepts timeout and retry parameters. Timeout applies per house, not to the
|
|
55
|
+
entire operation. Retries only trigger on network errors, not parsing failures:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
rates = await px.fetch_rates(timeout=15.0, max_retries=5)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Failed sources are silently skipped. The function returns whatever rates it successfully
|
|
62
|
+
fetched, or an empty list if everything fails. Network errors trigger automatic retries
|
|
63
|
+
with exponential backoff. Parsing errors fail immediately.
|
|
64
|
+
|
|
65
|
+
## Working with rates
|
|
66
|
+
|
|
67
|
+
Each `ExchangeRate` contains the house name, buy and sell prices, and a UTC timestamp. Buy
|
|
68
|
+
price is what you pay in soles to buy dollars. Sell price is what you receive in soles
|
|
69
|
+
when selling dollars. The object is a frozen dataclass:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
rate = rates[0]
|
|
73
|
+
name = rate.name
|
|
74
|
+
buy = rate.buy_price
|
|
75
|
+
sell = rate.sell_price
|
|
76
|
+
when = rate.timestamp
|
|
77
|
+
spread = rate.spread
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The spread property returns the difference between sell and buy prices. Some sources
|
|
81
|
+
return multiple tiers with different rates based on transaction amount, like
|
|
82
|
+
`tkambio_5000` and `tkambio_10000`.
|
|
83
|
+
|
|
84
|
+
Find the best rates by sorting or filtering the list:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
best_buy = min(rates, key=lambda r: r.buy_price)
|
|
88
|
+
best_sell = max(rates, key=lambda r: r.sell_price)
|
|
89
|
+
recent = [r for r in rates if (datetime.now(timezone.utc) - r.timestamp).seconds < 300]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Error handling
|
|
93
|
+
|
|
94
|
+
Invalid house names raise `ValueError` immediately. All other failures are silent. Check
|
|
95
|
+
the returned list to see what succeeded:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
try:
|
|
99
|
+
rates = await px.fetch_rates(houses=["nonexistent"])
|
|
100
|
+
except ValueError as e:
|
|
101
|
+
print(f"Unknown house: {e}")
|
|
102
|
+
|
|
103
|
+
rates = await px.fetch_rates()
|
|
104
|
+
if not rates:
|
|
105
|
+
print("All sources failed")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The library doesn't distinguish between different failure types. A house might fail due to
|
|
109
|
+
network issues, API changes, or parsing errors. Failed requests don't pollute your results
|
|
110
|
+
or logs.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fetch PEN-USD exchange rates from Peruvian exchange houses.
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
>>> import perexchange as px
|
|
6
|
+
>>> rates = await px.fetch_rates()
|
|
7
|
+
>>> best = min(rates, key=lambda r: r.buy_price)
|
|
8
|
+
>>> print(f"{best.name}: S/{best.buy_price}")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from perexchange.core import fetch_rates
|
|
12
|
+
from perexchange.models import ExchangeRate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
__all__ = ["ExchangeRate", "fetch_rates"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from perexchange.models import ExchangeRate
|
|
8
|
+
from perexchange.scrapers import get_scrapers
|
|
9
|
+
from perexchange.scrapers.base import ExchangeRateScraper
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def fetch_rates(
|
|
13
|
+
houses: Sequence[str] | None = None,
|
|
14
|
+
*,
|
|
15
|
+
timeout: float = 10.0,
|
|
16
|
+
max_retries: int = 3,
|
|
17
|
+
) -> list[ExchangeRate]:
|
|
18
|
+
"""
|
|
19
|
+
Fetch current exchange rates from Peruvian exchange houses.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
houses: Specific house names to fetch. If None, fetches all.
|
|
23
|
+
Available: cambiafx, cambioseguro, chapacambio, cuantoestaeldolar, dollarhouse,
|
|
24
|
+
instakash, srcambio, tkambio, tucambista, westernunion, yanki
|
|
25
|
+
timeout: Request timeout per house (seconds)
|
|
26
|
+
max_retries: Retry attempts for failed requests
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of ExchangeRate objects. Empty list if all houses fail.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> rates = await fetch_rates()
|
|
33
|
+
>>> rates = await fetch_rates(houses=["tkambio", "cambioseguro"])
|
|
34
|
+
>>> best = min(rates, key=lambda r: r.buy_price)
|
|
35
|
+
>>> print(f"Best: {best.name} at S/{best.buy_price}")
|
|
36
|
+
|
|
37
|
+
Note:
|
|
38
|
+
Failed houses are silently skipped. Network errors are retried,
|
|
39
|
+
parsing errors fail immediately.
|
|
40
|
+
"""
|
|
41
|
+
scrapers = get_scrapers(houses)
|
|
42
|
+
|
|
43
|
+
tasks = [_safe_fetch(scraper, timeout, max_retries) for scraper in scrapers]
|
|
44
|
+
results = await asyncio.gather(*tasks)
|
|
45
|
+
|
|
46
|
+
all_rates = [rate for result in results for rate in result]
|
|
47
|
+
|
|
48
|
+
# Deduplicate by name, keeping the most recent rate for each house.
|
|
49
|
+
# This handles cases where cuantoestaeldolar (an aggregator) returns rates for houses
|
|
50
|
+
# we also scrape directly (e.g., chapacambio). We prioritize the most recent timestamp,
|
|
51
|
+
# which is typically from direct scrapers since they have fresher data.
|
|
52
|
+
# This is a temporary measure until cuantoestaeldolar is replaced with individual scrapers.
|
|
53
|
+
seen: dict[str, ExchangeRate] = {}
|
|
54
|
+
for rate in all_rates:
|
|
55
|
+
if rate.name not in seen or rate.timestamp > seen[rate.name].timestamp:
|
|
56
|
+
seen[rate.name] = rate
|
|
57
|
+
|
|
58
|
+
return list(seen.values())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _safe_fetch(
|
|
62
|
+
scraper: ExchangeRateScraper,
|
|
63
|
+
timeout: float,
|
|
64
|
+
max_retries: int,
|
|
65
|
+
) -> list[ExchangeRate]:
|
|
66
|
+
"""Fetch from one scraper, return empty list on failure."""
|
|
67
|
+
try:
|
|
68
|
+
return await scraper(timeout=timeout, max_retries=max_retries)
|
|
69
|
+
except (httpx.HTTPError, ValueError):
|
|
70
|
+
return []
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class ExchangeRate:
|
|
7
|
+
name: str
|
|
8
|
+
buy_price: float # Price to BUY dollars (sell soles)
|
|
9
|
+
sell_price: float # Price to SELL dollars (buy soles)
|
|
10
|
+
timestamp: datetime
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def spread(self) -> float:
|
|
14
|
+
"""Difference between buy and sell price."""
|
|
15
|
+
return self.sell_price - self.buy_price
|
|
16
|
+
|
|
17
|
+
def __repr__(self) -> str:
|
|
18
|
+
return (
|
|
19
|
+
f"ExchangeRate({self.name!r}, "
|
|
20
|
+
f"buy={self.buy_price:.4f}, sell={self.sell_price:.4f})"
|
|
21
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from perexchange.scrapers.base import ExchangeRateScraper
|
|
4
|
+
from perexchange.scrapers.cambiafx import fetch_cambiafx
|
|
5
|
+
from perexchange.scrapers.cambioseguro import fetch_cambioseguro
|
|
6
|
+
from perexchange.scrapers.chapacambio import fetch_chapacambio
|
|
7
|
+
from perexchange.scrapers.cuantoestaeldolar import fetch_cuantoestaeldolar
|
|
8
|
+
from perexchange.scrapers.dollarhouse import fetch_dollarhouse
|
|
9
|
+
from perexchange.scrapers.instakash import fetch_instakash
|
|
10
|
+
from perexchange.scrapers.srcambio import fetch_srcambio
|
|
11
|
+
from perexchange.scrapers.tkambio import fetch_tkambio
|
|
12
|
+
from perexchange.scrapers.tucambista import fetch_tucambista
|
|
13
|
+
from perexchange.scrapers.westernunion import fetch_westernunion
|
|
14
|
+
from perexchange.scrapers.yanki import fetch_yanki
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_SCRAPERS: dict[str, ExchangeRateScraper] = {
|
|
18
|
+
"cambioseguro": fetch_cambioseguro,
|
|
19
|
+
"cambiafx": fetch_cambiafx,
|
|
20
|
+
"chapacambio": fetch_chapacambio,
|
|
21
|
+
"cuantoestaeldolar": fetch_cuantoestaeldolar,
|
|
22
|
+
"dollarhouse": fetch_dollarhouse,
|
|
23
|
+
"instakash": fetch_instakash,
|
|
24
|
+
"srcambio": fetch_srcambio,
|
|
25
|
+
"tkambio": fetch_tkambio,
|
|
26
|
+
"tucambista": fetch_tucambista,
|
|
27
|
+
"westernunion": fetch_westernunion,
|
|
28
|
+
"yanki": fetch_yanki,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_scrapers(houses: Sequence[str] | None = None) -> list[ExchangeRateScraper]:
|
|
33
|
+
"""
|
|
34
|
+
Get scrapers for specified houses, or all if None.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If a house name is not recognized
|
|
38
|
+
"""
|
|
39
|
+
if houses is None:
|
|
40
|
+
return list(_SCRAPERS.values())
|
|
41
|
+
|
|
42
|
+
scrapers = []
|
|
43
|
+
for house in houses:
|
|
44
|
+
house_lower = house.lower()
|
|
45
|
+
if house_lower not in _SCRAPERS:
|
|
46
|
+
available = ", ".join(sorted(_SCRAPERS.keys()))
|
|
47
|
+
msg = f"Unknown house: {house!r}. Available: {available}"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
scrapers.append(_SCRAPERS[house_lower])
|
|
50
|
+
|
|
51
|
+
return scrapers
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"ExchangeRateScraper",
|
|
56
|
+
"fetch_cambiafx",
|
|
57
|
+
"fetch_cambioseguro",
|
|
58
|
+
"fetch_chapacambio",
|
|
59
|
+
"fetch_cuantoestaeldolar",
|
|
60
|
+
"fetch_dollarhouse",
|
|
61
|
+
"fetch_instakash",
|
|
62
|
+
"fetch_srcambio",
|
|
63
|
+
"fetch_tkambio",
|
|
64
|
+
"fetch_tucambista",
|
|
65
|
+
"fetch_westernunion",
|
|
66
|
+
"fetch_yanki",
|
|
67
|
+
"get_scrapers",
|
|
68
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Protocol, TypeVar
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from perexchange.models import ExchangeRate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExchangeRateScraper(Protocol):
|
|
16
|
+
"""Protocol defining the interface all scrapers must implement."""
|
|
17
|
+
|
|
18
|
+
def __call__(
|
|
19
|
+
self,
|
|
20
|
+
timeout: float = 10.0,
|
|
21
|
+
max_retries: int = 3,
|
|
22
|
+
retry_delay: float = 0.5,
|
|
23
|
+
) -> Awaitable[list[ExchangeRate]]: # fmt: skip
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@asynccontextmanager
|
|
28
|
+
async def get_http_client(timeout: float) -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
29
|
+
"""
|
|
30
|
+
Create HTTP client with connection pooling.
|
|
31
|
+
"""
|
|
32
|
+
async with httpx.AsyncClient(
|
|
33
|
+
timeout=timeout,
|
|
34
|
+
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
|
|
35
|
+
http2=True,
|
|
36
|
+
) as client:
|
|
37
|
+
yield client
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def fetch_with_retry(
|
|
41
|
+
fetch_fn: Callable[[httpx.AsyncClient], Awaitable[T]],
|
|
42
|
+
timeout: float,
|
|
43
|
+
max_retries: int,
|
|
44
|
+
retry_delay: float,
|
|
45
|
+
error_context: str,
|
|
46
|
+
) -> T:
|
|
47
|
+
"""
|
|
48
|
+
Execute fetch function with exponential backoff retry logic.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
fetch_fn: Async function that takes an httpx.AsyncClient and returns data
|
|
52
|
+
timeout: Request timeout in seconds
|
|
53
|
+
max_retries: Maximum number of retry attempts
|
|
54
|
+
retry_delay: Base delay between retries (doubles each attempt)
|
|
55
|
+
error_context: URL or context string for error messages
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Result from fetch_fn
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: On parsing errors (fails immediately, no retry)
|
|
62
|
+
httpx.HTTPError: On network errors after all retries exhausted
|
|
63
|
+
"""
|
|
64
|
+
async with get_http_client(timeout) as client:
|
|
65
|
+
last_error = None
|
|
66
|
+
|
|
67
|
+
for attempt in range(max_retries):
|
|
68
|
+
try:
|
|
69
|
+
return await fetch_fn(client)
|
|
70
|
+
|
|
71
|
+
except httpx.HTTPError as e:
|
|
72
|
+
last_error = e
|
|
73
|
+
if attempt < max_retries - 1:
|
|
74
|
+
await asyncio.sleep(retry_delay * (2**attempt))
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
except (ValueError, KeyError, TypeError, AttributeError, IndexError) as e:
|
|
78
|
+
msg = (
|
|
79
|
+
f"Failed to parse exchange rates from {error_context}. "
|
|
80
|
+
"The structure may have changed."
|
|
81
|
+
)
|
|
82
|
+
raise ValueError(msg) from e
|
|
83
|
+
|
|
84
|
+
if last_error is None:
|
|
85
|
+
msg = "Failed to fetch rates: no attempts were made"
|
|
86
|
+
raise ValueError(msg)
|
|
87
|
+
raise last_error
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from perexchange.models import ExchangeRate
|
|
7
|
+
from perexchange.scrapers.base import fetch_with_retry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
URL = "https://apiluna.cambiafx.pe/api/BackendPizarra/getTcCustomerNoAuth?idParCurrency=1&codePromo=CED"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def fetch_cambiafx(
|
|
14
|
+
timeout: float = 10.0,
|
|
15
|
+
max_retries: int = 3,
|
|
16
|
+
retry_delay: float = 0.5,
|
|
17
|
+
) -> list[ExchangeRate]:
|
|
18
|
+
async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
|
|
19
|
+
response = await client.get(URL)
|
|
20
|
+
response.raise_for_status()
|
|
21
|
+
return _parse_json(response.json())
|
|
22
|
+
|
|
23
|
+
return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_json(response_data: list[dict[str, Any]]) -> list[ExchangeRate]:
|
|
27
|
+
if not response_data:
|
|
28
|
+
msg = "No exchange rates data"
|
|
29
|
+
raise ValueError(msg)
|
|
30
|
+
|
|
31
|
+
timestamp = datetime.now(timezone.utc)
|
|
32
|
+
rates = []
|
|
33
|
+
|
|
34
|
+
# Take the first rate from the array
|
|
35
|
+
data = response_data[0]
|
|
36
|
+
try:
|
|
37
|
+
buy_price = float(data["tcBuy"])
|
|
38
|
+
sell_price = float(data["tcSale"])
|
|
39
|
+
if buy_price > 0 and sell_price > 0:
|
|
40
|
+
rate = ExchangeRate(
|
|
41
|
+
name="cambiafx",
|
|
42
|
+
buy_price=buy_price,
|
|
43
|
+
sell_price=sell_price,
|
|
44
|
+
timestamp=timestamp,
|
|
45
|
+
)
|
|
46
|
+
rates.append(rate)
|
|
47
|
+
except (KeyError, ValueError, TypeError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
if not rates:
|
|
51
|
+
msg = "No valid exchange rates parsed"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
|
|
54
|
+
return rates
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from perexchange.models import ExchangeRate
|
|
7
|
+
from perexchange.scrapers.base import fetch_with_retry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
URL = "https://api.cambioseguro.com/api/v1.1/config/rates"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def fetch_cambioseguro(
|
|
14
|
+
timeout: float = 10.0,
|
|
15
|
+
max_retries: int = 3,
|
|
16
|
+
retry_delay: float = 0.5,
|
|
17
|
+
) -> list[ExchangeRate]:
|
|
18
|
+
async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
|
|
19
|
+
response = await client.get(URL)
|
|
20
|
+
response.raise_for_status()
|
|
21
|
+
return _parse_json(response.json())
|
|
22
|
+
|
|
23
|
+
return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_json(response_data: dict[str, Any]) -> list[ExchangeRate]:
|
|
27
|
+
timestamp = datetime.now(timezone.utc)
|
|
28
|
+
rates = []
|
|
29
|
+
data = response_data.get("data", {})
|
|
30
|
+
|
|
31
|
+
rate_configs = [
|
|
32
|
+
("cambioseguro", "purchase_price", "sale_price"),
|
|
33
|
+
(
|
|
34
|
+
"cambioseguro_comparative",
|
|
35
|
+
"purchase_price_comparative",
|
|
36
|
+
"sale_price_comparative",
|
|
37
|
+
),
|
|
38
|
+
("cambioseguro_paralelo", "purchase_price_paralelo", "sale_price_paralelo"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for name, buy_key, sell_key in rate_configs:
|
|
42
|
+
rate = _try_create_rate(data, name, buy_key, sell_key, timestamp)
|
|
43
|
+
if rate:
|
|
44
|
+
rates.append(rate)
|
|
45
|
+
|
|
46
|
+
if not rates:
|
|
47
|
+
msg = "No valid exchange rates parsed"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
|
|
50
|
+
return rates
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _try_create_rate(
|
|
54
|
+
data: dict[str, Any],
|
|
55
|
+
name: str,
|
|
56
|
+
buy_key: str,
|
|
57
|
+
sell_key: str,
|
|
58
|
+
timestamp: datetime,
|
|
59
|
+
) -> ExchangeRate | None:
|
|
60
|
+
"""Try to create a rate from data, return None if invalid."""
|
|
61
|
+
try:
|
|
62
|
+
buy_price = float(data[buy_key])
|
|
63
|
+
sell_price = float(data[sell_key])
|
|
64
|
+
if buy_price > 0 and sell_price > 0:
|
|
65
|
+
return ExchangeRate(
|
|
66
|
+
name=name,
|
|
67
|
+
buy_price=buy_price,
|
|
68
|
+
sell_price=sell_price,
|
|
69
|
+
timestamp=timestamp,
|
|
70
|
+
)
|
|
71
|
+
except (KeyError, ValueError, TypeError):
|
|
72
|
+
pass
|
|
73
|
+
return None
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from perexchange.models import ExchangeRate
|
|
7
|
+
from perexchange.scrapers.base import fetch_with_retry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
URL = "https://chapacambio.com/wp-json/chapacambio/tasas"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def fetch_chapacambio(
|
|
14
|
+
timeout: float = 10.0,
|
|
15
|
+
max_retries: int = 3,
|
|
16
|
+
retry_delay: float = 0.5,
|
|
17
|
+
) -> list[ExchangeRate]:
|
|
18
|
+
async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
|
|
19
|
+
response = await client.get(URL)
|
|
20
|
+
response.raise_for_status()
|
|
21
|
+
return _parse_json(response.json())
|
|
22
|
+
|
|
23
|
+
return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_json(response_data: list[dict[str, Any]]) -> list[ExchangeRate]:
|
|
27
|
+
rates = []
|
|
28
|
+
for item in response_data:
|
|
29
|
+
rate = _try_create_rate(item)
|
|
30
|
+
if rate:
|
|
31
|
+
rates.append(rate)
|
|
32
|
+
|
|
33
|
+
if not rates:
|
|
34
|
+
msg = "No valid exchange rates parsed"
|
|
35
|
+
raise ValueError(msg)
|
|
36
|
+
|
|
37
|
+
return rates
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _try_create_rate(data: dict[str, Any]) -> ExchangeRate | None:
|
|
41
|
+
"""Try to create a rate from data, return None if invalid."""
|
|
42
|
+
try:
|
|
43
|
+
buy_price = float(data["MontoCompra"])
|
|
44
|
+
sell_price = float(data["MontoVenta"])
|
|
45
|
+
if buy_price > 0 and sell_price > 0:
|
|
46
|
+
timestamp_str = data.get("updateAt")
|
|
47
|
+
if timestamp_str:
|
|
48
|
+
timestamp = datetime.fromisoformat(timestamp_str).replace(
|
|
49
|
+
tzinfo=timezone.utc
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
timestamp = datetime.now(timezone.utc)
|
|
53
|
+
return ExchangeRate(
|
|
54
|
+
name="chapacambio",
|
|
55
|
+
buy_price=buy_price,
|
|
56
|
+
sell_price=sell_price,
|
|
57
|
+
timestamp=timestamp,
|
|
58
|
+
)
|
|
59
|
+
except (KeyError, ValueError, TypeError):
|
|
60
|
+
pass
|
|
61
|
+
return None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from bs4 import BeautifulSoup
|
|
6
|
+
from bs4.element import Tag
|
|
7
|
+
|
|
8
|
+
from perexchange.models import ExchangeRate
|
|
9
|
+
from perexchange.scrapers.base import fetch_with_retry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
URL = "https://cuantoestaeldolar.pe/cambio-de-dolar-online"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def fetch_cuantoestaeldolar(
|
|
16
|
+
timeout: float = 10.0,
|
|
17
|
+
max_retries: int = 3,
|
|
18
|
+
retry_delay: float = 0.5,
|
|
19
|
+
) -> list[ExchangeRate]:
|
|
20
|
+
async def _fetch(client: httpx.AsyncClient) -> list[ExchangeRate]:
|
|
21
|
+
response = await client.get(URL)
|
|
22
|
+
response.raise_for_status()
|
|
23
|
+
return _parse_html(response.text)
|
|
24
|
+
|
|
25
|
+
return await fetch_with_retry(_fetch, timeout, max_retries, retry_delay, URL)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_html(html_content: str) -> list[ExchangeRate]:
|
|
29
|
+
soup = BeautifulSoup(html_content, "lxml")
|
|
30
|
+
change_buttons = soup.find_all("a", string="CAMBIAR") # type: ignore[call-overload]
|
|
31
|
+
|
|
32
|
+
if not change_buttons:
|
|
33
|
+
msg = "No exchange houses found in HTML"
|
|
34
|
+
raise ValueError(msg)
|
|
35
|
+
|
|
36
|
+
timestamp = datetime.now(timezone.utc)
|
|
37
|
+
rates = []
|
|
38
|
+
|
|
39
|
+
for button in change_buttons:
|
|
40
|
+
try:
|
|
41
|
+
rate = _extract_rate_from_card(button, timestamp)
|
|
42
|
+
if rate:
|
|
43
|
+
rates.append(rate)
|
|
44
|
+
except (AttributeError, ValueError, TypeError, IndexError):
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
if not rates:
|
|
48
|
+
msg = "No valid exchange rates parsed"
|
|
49
|
+
raise ValueError(msg)
|
|
50
|
+
|
|
51
|
+
return rates
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_rate_from_card(button: Tag, timestamp: datetime) -> ExchangeRate | None:
|
|
55
|
+
parent = button.find_parent("div")
|
|
56
|
+
if not parent:
|
|
57
|
+
return None
|
|
58
|
+
card = parent.find_parent("div")
|
|
59
|
+
if not card:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
img_tag = card.find("img")
|
|
63
|
+
if not img_tag:
|
|
64
|
+
return None
|
|
65
|
+
alt_attr = img_tag.get("alt")
|
|
66
|
+
name = alt_attr.strip() if isinstance(alt_attr, str) else None
|
|
67
|
+
if not name:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
buy_block = card.select_one('div[class*="_content_buy__"]')
|
|
71
|
+
sell_block = card.select_one('div[class*="_content_sale__"]')
|
|
72
|
+
|
|
73
|
+
if not buy_block or not sell_block:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
buy_rate_elem = buy_block.find(
|
|
77
|
+
"p", class_=lambda c: c and c.startswith("ValueCurrency_item_cost__")
|
|
78
|
+
)
|
|
79
|
+
buy_price = float(buy_rate_elem.text) if buy_rate_elem else None
|
|
80
|
+
|
|
81
|
+
sell_rate_elem = sell_block.find(
|
|
82
|
+
"p", class_=lambda c: c and c.startswith("ValueCurrency_item_cost__")
|
|
83
|
+
)
|
|
84
|
+
sell_price = float(sell_rate_elem.text) if sell_rate_elem else None
|
|
85
|
+
|
|
86
|
+
if not buy_price or not sell_price or buy_price <= 0 or sell_price <= 0:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return ExchangeRate(
|
|
90
|
+
name=name,
|
|
91
|
+
buy_price=buy_price,
|
|
92
|
+
sell_price=sell_price,
|
|
93
|
+
timestamp=timestamp,
|
|
94
|
+
)
|