@mixxtor/currencyx-js
Version:
Modern TypeScript currency converter with type inference and multiple providers (Google Finance, Fixer.io). Framework agnostic with clean architecture.
171 lines (150 loc) • 5.1 kB
text/typescript
/**
* Google Finance Exchange
*/
import type {
CurrencyCode,
ConversionResult,
ExchangeRatesResult,
GoogleFinanceConfig,
ExchangeRatesParams,
ConvertParams,
} from '../types/index.js'
import { BaseCurrencyExchange } from './base_exchange.js'
export class GoogleFinanceExchange extends BaseCurrencyExchange {
readonly name = 'google'
private baseUrl = 'https://www.google.com/finance'
private timeout: number
constructor(config: GoogleFinanceConfig = {}) {
super()
this.base = config.base || 'USD'
this.timeout = config.timeout || 5000
}
/**
* Get latest exchange rates
*/
async latestRates(params?: ExchangeRatesParams): Promise<ExchangeRatesResult> {
const rates: Record<string, number> = {}
const currenciesToFetch = params?.codes || this.currencies
try {
for (const code of currenciesToFetch) {
if (code === this.base) {
rates[code] = 1.0
continue
}
const rate = await this.#getRate(this.base, code)
if (rate) {
rates[code] = rate
}
}
return this.createExchangeRatesResult(this.base, rates)
} catch (error) {
return this.createExchangeRatesResult(
this.base,
{},
{
info: error instanceof Error ? error.message : 'Failed to fetch exchange rates',
type: 'FETCH_ERROR',
}
)
}
}
/**
* Convert currency amount
*/
async convert(params: ConvertParams): Promise<ConversionResult> {
const { amount, from, to } = params
try {
if (from === to) {
return this.createConversionResult(amount, from, to, amount, 1.0)
}
const rate = await this.#getRate(from, to)
if (!rate) {
return this.createConversionResult(amount, from, to, undefined, undefined, {
info: `Failed to get exchange rate for ${from}-${to}`,
type: 'RATE_NOT_FOUND',
})
}
const result = rate * amount
return this.createConversionResult(amount, from, to, result, rate)
} catch (error) {
return this.createConversionResult(amount, from, to, undefined, undefined, {
info: error instanceof Error ? error.message : 'Conversion failed',
type: 'CONVERSION_ERROR',
})
}
}
/**
* Get conversion rate between two currencies
*/
async getConvertRate(from: CurrencyCode, to: CurrencyCode): Promise<number | undefined> {
return await this.#getRate(from, to)
}
/**
* Get exchange rate from Google Finance
*/
async #getRate(from: CurrencyCode, to: CurrencyCode): Promise<number | undefined> {
try {
const url = `${this.baseUrl}/quote/${from}-${to}`
const userAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'
const response = await fetch(url, {
headers: { 'User-Agent': userAgent },
signal: AbortSignal.timeout(this.timeout),
})
if (!response.ok) {
console.error(`Google Finance: HTTP ${response.status} for ${from}-${to}`)
return undefined
}
const html = await response.text()
const rate = this.#parseRateFromHtml(html, from, to)
if (rate && !isNaN(rate)) {
return rate
} else {
console.error(`Google Finance: Failed to get ${from}-${to} rate.`)
}
} catch (error) {
console.error(error)
}
}
/**
* Parse exchange rate from Google Finance HTML
*/
#parseRateFromHtml(html: string, from: CurrencyCode, to: CurrencyCode): number | undefined {
try {
const patterns = [
// Pattern: data-source/data-target div with first child text content
new RegExp(
`data-source="${from}"[^>]*data-target="${to}"[^>]*>\\s*<[^>]*>([0-9][0-9,]*\\.?[0-9]*)`,
'i'
),
// Pattern for data-source and data-target attributes (deeper nesting)
new RegExp(
`data-source="${from}"[^>]*data-target="${to}"[^>]*>([^<]*<[^>]*>)*([0-9,]+\\.?[0-9]*)`,
'i'
),
// Pattern for currency pair in title or aria-label
new RegExp(`${from}\\s*-\\s*${to}[^0-9]*([0-9,]+\\.?[0-9]*)`, 'i'),
// Pattern for rate value in common Google Finance structure
new RegExp(`"${from}-${to}"[^}]*"price"[^:]*:[^"]*"([0-9,]+\\.?[0-9]*)"`, 'i'),
// Fallback pattern for any number after currency pair
new RegExp(`${from}/${to}[^0-9]*([0-9,]+\\.?[0-9]*)`, 'i'),
]
for (const pattern of patterns) {
const match = html.match(pattern)
// Try the last captured group first (group 2 if exists, else group 1)
const rateString = match?.[2] ?? match?.[1]
if (rateString) {
const cleaned = rateString.replace(/,/g, '')
const rate = parseFloat(cleaned)
if (!isNaN(rate) && rate > 0) {
return rate
}
}
}
return undefined
} catch (error) {
console.error('Error parsing rate from HTML:', error)
return undefined
}
}
}