├── .prettierrc ├── .husky └── pre-commit ├── dist └── readme.md ├── LICENSE ├── Dockerfile ├── pm2.json ├── vercel.json ├── jest.config.ts ├── src ├── constant.ts ├── FXGetter │ ├── hsbc.cn.ts │ ├── icbc.ts │ ├── cmb.ts │ ├── hsbc.au.ts │ ├── jcb.ts │ ├── psbc.ts │ ├── hsbc.hk.ts │ ├── ncb.hk.ts │ ├── wise.ts │ ├── ncb.cn.ts │ ├── pab.ts │ ├── xib.ts │ ├── spdb.d.ts │ ├── citic.cn.ts │ ├── bocom.ts │ ├── ceb.ts │ ├── abc.ts │ ├── pboc.ts │ ├── unionpay.ts │ ├── spdb.ts │ ├── ccb.ts │ ├── bochk.ts │ ├── cib.ts │ ├── boc.ts │ ├── mastercard.ts │ └── visa.ts ├── types.d.ts ├── handler │ └── rss.ts ├── index.ts ├── client │ └── index.ts ├── fxm │ └── fxManager.ts └── fxmManager.ts ├── tsconfig.json ├── LICENSE.MIT ├── test └── server-status.test.ts ├── eslint.config.js ├── LICENSE.DATA ├── package.json ├── .github └── workflows │ └── cd.yml ├── .gitignore └── readme.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 4 5 | } -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yarn lint 4 | yarn format 5 | # yarn test 6 | yarn build 7 | 8 | git add -A 9 | 10 | exit 0 -------------------------------------------------------------------------------- /dist/readme.md: -------------------------------------------------------------------------------- 1 | # Why we need that's stupid index.cjs? 2 | 3 | becuz the Vercel doesn't support `yarn build` when I use `builds` in vercel.json 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Program's code is under MIT LICENSE (SEE LICENSE IN LICENSE.MIT). 2 | 3 | Data copyright belongs to its source (SEE LICENSE IN LICENSE.DATA). -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | COPY package.json ./ 3 | RUN yarn global add pnpm pm2 husky && pnpm install -P 4 | 5 | WORKDIR /app 6 | 7 | COPY pm2.json ./ 8 | COPY dist ./dist 9 | 10 | CMD [ "pm2-runtime", "start", "pm2.json" ] 11 | -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxrate", 3 | "script": "dist/index.cjs", 4 | "instances": "1", 5 | "env": { 6 | "NODE_ENV": "development" 7 | }, 8 | "env_production": { 9 | "NODE_ENV": "production" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "/dist/index.cjs", 6 | "use": "@vercel/node", 7 | "config": { "includeFiles": ["dist/**"] } 8 | } 9 | ], 10 | "routes": [ 11 | { 12 | "src": "/(.*)", 13 | "dest": "/dist/index.cjs" 14 | } 15 | ], 16 | "buildCommand": "yarn build", 17 | "installCommand": "yarn install" 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | // jest.config.ts 2 | import type { JestConfigWithTsJest } from 'ts-jest'; 3 | 4 | const jestConfig: JestConfigWithTsJest = { 5 | // [...] 6 | preset: 'ts-jest/presets/default-esm', // or other ESM presets 7 | moduleNameMapper: { 8 | '^(\\.{1,2}/.*)\\.js$': '$1', 9 | }, 10 | transform: { 11 | // '^.+\\.[tj]sx?$' to process ts,js,tsx,jsx with `ts-jest` 12 | // '^.+\\.m?[tj]sx?$' to process ts,js,tsx,jsx,mts,mjs,mtsx,mjsx with `ts-jest` 13 | '^.+\\.tsx?$': [ 14 | 'ts-jest', 15 | { 16 | useESM: true, 17 | }, 18 | ], 19 | }, 20 | }; 21 | 22 | export default jestConfig; 23 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | export const sourceNamesInZH = { 2 | pboc: '中国人民银行', 3 | unionpay: '银联', 4 | mastercard: 'MasterCard', 5 | wise: 'Wise', 6 | visa: 'Visa', 7 | jcb: 'JCB', 8 | abc: '中国农业银行', 9 | cmb: '招商银行', 10 | icbc: '中国工商银行', 11 | boc: '中国银行', 12 | bochk: '中银香港', 13 | ccb: '中国建设银行', 14 | psbc: '邮政储蓄银行', 15 | bocom: '交通银行', 16 | cibHuanyu: '兴业银行寰宇人生', 17 | cib: '兴业银行', 18 | 'hsbc.cn': '汇丰中国', 19 | 'hsbc.hk': '汇丰香港', 20 | 'hsbc.au': '汇丰澳洲', 21 | 'citic.cn': '中信银行', 22 | spdb: '浦发银行', 23 | 'ncb.cn': '南洋商业银行(中国)', 24 | 'ncb.hk': '南洋商业银行(香港)', 25 | xib: '厦门国际银行', 26 | pab: '平安银行', 27 | ceb: '中国光大银行', 28 | }; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": ".", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "moduleResolution": "node", 16 | "ignoreDeprecations": "6.0", 17 | "resolveJsonModule": true 18 | }, 19 | "exclude": ["dist", "node_modules"], 20 | "include": ["src/**/*", "tests/**/*"], 21 | "ts-node": { 22 | "esm": true 23 | }, 24 | "tsc-alias": { 25 | "resolveFullPaths": true, 26 | "verbose": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE.MIT: -------------------------------------------------------------------------------- 1 | Copyright 2024-NOW Bo Xu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /test/server-status.test.ts: -------------------------------------------------------------------------------- 1 | import { makeInstance, Manager } from '../src/index'; 2 | import { useInternalRestAPI } from '../src/fxmManager'; 3 | import { rootRouter } from 'handlers.js'; 4 | 5 | const Instance = await makeInstance(new rootRouter(), Manager); 6 | 7 | describe('Server Status', () => { 8 | test('/info', async () => { 9 | const res = await useInternalRestAPI('info', Instance); 10 | expect(res.status).toEqual('ok'); 11 | }); 12 | 13 | test( 14 | '/:sources/', 15 | async () => { 16 | const res = await useInternalRestAPI('info', Instance); 17 | expect(res.status).toEqual('ok'); 18 | 19 | await Promise.all( 20 | res.sources.map(async (source) => { 21 | const p = await useInternalRestAPI(`${source}`, Instance); 22 | expect(p.status).toEqual('ok'); 23 | }), 24 | ); 25 | 26 | await new Promise((resolve) => setTimeout(resolve, 5 * 1000)); 27 | }, 28 | 45 * 1000, 29 | ); 30 | }); 31 | 32 | afterAll((t) => { 33 | Manager.stopAllInterval(); 34 | t(); 35 | }); 36 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | 5 | export default [ 6 | { files: ['src/**/*.{js,mjs,cjs,ts}', 'test/**/*.{js,mjs,cjs,ts}'] }, 7 | { languageOptions: { globals: globals.browser } }, 8 | pluginJs.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | { 11 | rules: { 12 | '@typescript-eslint/interface-name-prefix': 'off', 13 | '@typescript-eslint/explicit-function-return-type': 'off', 14 | '@typescript-eslint/explicit-module-boundary-types': 'off', 15 | '@typescript-eslint/no-explicit-any': 'off', 16 | '@typescript-eslint/no-unused-vars': [ 17 | 'error', 18 | { 19 | args: 'all', 20 | argsIgnorePattern: '^_', 21 | caughtErrors: 'all', 22 | caughtErrorsIgnorePattern: '^_', 23 | destructuredArrayIgnorePattern: '^_', 24 | varsIgnorePattern: '^_', 25 | ignoreRestSiblings: true, 26 | }, 27 | ], 28 | }, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /src/FXGetter/hsbc.cn.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { currency, FXRate } from 'src/types'; 4 | 5 | const getHSBCCNFXRates = async (): Promise => { 6 | const req = await axios.get( 7 | 'https://www.services.cn-banking.hsbc.com.cn/mobile/channel/digital-proxy/cnyTransfer/ratesInfo/remittanceRate?locale=en_CN', 8 | { 9 | headers: { 10 | 'User-Agent': 11 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 12 | 'Content-Type': 'application/json', 13 | }, 14 | }, 15 | ); 16 | 17 | const data = req.data.data.counterForRepeatingBlock; 18 | 19 | return data.map((k) => { 20 | return { 21 | currency: { 22 | from: 'CNY' as currency.CNY, 23 | to: k.exchangeRateCurrency as currency.unknown, 24 | }, 25 | rate: { 26 | buy: { 27 | cash: parseFloat(k.notesSellingRate), 28 | remit: parseFloat(k.transferSellingRate), 29 | }, 30 | sell: { 31 | cash: parseFloat(k.notesBuyingRate), 32 | remit: parseFloat(k.transferBuyingRate), 33 | }, 34 | }, 35 | unit: 1, 36 | updated: new Date(), 37 | }; 38 | }); 39 | }; 40 | 41 | export default getHSBCCNFXRates; 42 | -------------------------------------------------------------------------------- /src/FXGetter/icbc.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FXRate, currency } from 'src/types'; 3 | 4 | const getICBCFXRates = async (): Promise => { 5 | const res = await axios.get( 6 | 'http://papi.icbc.com.cn/exchanges/ns/getLatest', 7 | { 8 | headers: { 9 | 'User-Agent': 10 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 11 | }, 12 | }, 13 | ); 14 | 15 | const data = res.data; 16 | 17 | const FXRates: FXRate[] = []; 18 | 19 | if (data.code != 0) throw new Error(`Get ICBC FX Rates failed.`); 20 | 21 | data.data.forEach((fx) => { 22 | FXRates.push({ 23 | currency: { 24 | from: fx.currencyENName as currency.unknown, 25 | to: 'CNY' as currency.CNY, 26 | }, 27 | rate: { 28 | buy: { 29 | remit: fx.foreignBuy, 30 | cash: fx.cashBuy, 31 | }, 32 | sell: { 33 | remit: fx.foreignSell, 34 | cash: fx.cashSell, 35 | }, 36 | middle: fx.reference, 37 | }, 38 | unit: 100, 39 | updated: new Date(`${fx.publishDate} ${fx.publishTime} UTC+8`), 40 | }); 41 | }); 42 | 43 | return FXRates.sort(); 44 | }; 45 | 46 | export default getICBCFXRates; 47 | -------------------------------------------------------------------------------- /LICENSE.DATA: -------------------------------------------------------------------------------- 1 | Agricultural Bank of China Limited provides a source of foreign exchange rates. 2 | 3 | Bank of China Limited provides a source of foreign exchange rates. 4 | Bank of Communications Co., Ltd. provides a source of foreign exchange rates. 5 | 6 | China Construction Bank Corporation provides a source of foreign exchange rates. 7 | China Merchants Bank Co., Ltd. provides a source of foreign exchange rates. 8 | 9 | Industrial Bank Co., Ltd. provides a source of foreign exchange rates. 10 | Industrial and Commercial Bank of China Limited provides a source of foreign exchange rates. 11 | 12 | People's Bank of China provides a source of foreign exchange rates. 13 | Postal Savings Bank of China Co., Ltd. provides a source of foreign exchange rates. 14 | 15 | UnionPay International Co., Ltd. provides a source of foreign exchange rates. 16 | Visa Inc. provides a source of foreign exchange rates. 17 | Mastercard Incorporated provides a source of foreign exchange rates. 18 | 19 | Wise provides a source of foreign exchange rates. 20 | 21 | This project uses and not only contains the exchange rate information provided by the above platforms/banks. 22 | All exchange rate information is provided only by the platform, and the copyright of the data belongs to them. 23 | -------------------------------------------------------------------------------- /src/FXGetter/cmb.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FXRate, currency } from 'src/types'; 3 | 4 | const getCMBFXRates = async (): Promise => { 5 | const req = await axios.get('https://fx.cmbchina.com/api/v1/fx/rate', { 6 | headers: { 7 | 'User-Agent': 8 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 9 | }, 10 | }); 11 | 12 | const data = req.data.body; 13 | 14 | return data 15 | .map((fx) => { 16 | return { 17 | currency: { 18 | from: fx.ccyNbrEng.split(' ')[1] as currency.unknown, 19 | to: 'CNY' as currency.CNY, 20 | }, 21 | rate: { 22 | buy: { 23 | remit: fx.rthBid, 24 | cash: fx.rtcBid, 25 | }, 26 | sell: { 27 | remit: fx.rthOfr, 28 | cash: fx.rtcOfr, 29 | }, 30 | middle: fx.rtbBid, 31 | }, 32 | unit: 100, 33 | updated: new Date( 34 | `${fx.ratDat 35 | .replaceAll('年', '-') 36 | .replaceAll('月', '-') 37 | .replaceAll('日', '')} ${fx.ratTim} UTC+8`, 38 | ), 39 | } as FXRate; 40 | }) 41 | .sort(); 42 | }; 43 | 44 | export default getCMBFXRates; 45 | -------------------------------------------------------------------------------- /src/FXGetter/hsbc.au.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { currency, FXRate } from 'src/types'; 4 | 5 | const getHSBCAUFXRates = async (): Promise => { 6 | const req = await axios.get( 7 | `https://mkdlc.ebanking.hsbc.com.hk/hsbcfxwidget/data/getFXList?callback=JSON.stringify&token=0vg8cORxRLBsrWg9C9UboMT%2BkN2Ykze6vFnRV1nA8DE%3D`, 8 | { 9 | headers: { 10 | 'User-Agent': 11 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 12 | }, 13 | }, 14 | ); 15 | 16 | const data = JSON.parse([eval][0](req.data)).data; 17 | 18 | const date = new Date(req.headers['date']); 19 | 20 | const answer: FXRate[] = data.fxList.map((k) => { 21 | return { 22 | currency: { 23 | from: 'AUD' as currency.AUD, 24 | to: k.curr_s as currency.unknown, 25 | }, 26 | rate: { 27 | sell: { 28 | cash: k.buy, 29 | remit: k.buy, 30 | }, 31 | buy: { 32 | cash: k.sell, 33 | remit: k.sell, 34 | }, 35 | }, 36 | unit: 1, 37 | updated: date, 38 | } as FXRate; 39 | }); 40 | 41 | answer.push( 42 | ((answer) => { 43 | const tmp = answer.find((k) => k.currency.to === 'CNY'); 44 | tmp.currency.to = 'CNH' as currency.CNH; 45 | return tmp; 46 | })(answer), 47 | ); 48 | 49 | return answer; 50 | }; 51 | 52 | export default getHSBCAUFXRates; 53 | -------------------------------------------------------------------------------- /src/FXGetter/jcb.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FXRate, currency } from 'src/types'; 3 | import cheerio from 'cheerio'; 4 | 5 | const getJCBJPYBasedFXRates = async (): Promise => { 6 | const res = await axios.get('https://www.jcb.jp/rate/jpy.html', { 7 | headers: { 8 | 'User-Agent': 9 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 10 | }, 11 | }); 12 | 13 | const $ = cheerio.load(res.data); 14 | 15 | const date = new Date( 16 | $($('.rate2TableArea>p')[0]) 17 | .text() 18 | .replaceAll('換算日の基準レート', '') 19 | .replaceAll('日', '') 20 | .replaceAll('月', '-') 21 | .replaceAll('年', '-') + ' UTC+9', 22 | ); 23 | 24 | return $('.rate2TableArea>table>tbody>tr') 25 | .toArray() 26 | .map((el) => { 27 | const e = $(el); 28 | const currency = e.find('td:nth-child(1)').text(); 29 | const midPrice = e.find('td:nth-child(4)').text(); 30 | 31 | return { 32 | currency: { 33 | from: currency as currency, 34 | to: 'JPY' as currency.JPY, 35 | }, 36 | rate: { 37 | middle: parseFloat(midPrice), 38 | }, 39 | unit: 1, 40 | updated: date, 41 | } as FXRate; 42 | }) 43 | .sort(); 44 | }; 45 | 46 | const getJCBFXRates = async (): Promise => { 47 | const k = await Promise.all([getJCBJPYBasedFXRates()]); 48 | return k.flat(1); 49 | }; 50 | 51 | export default getJCBFXRates; 52 | export { getJCBJPYBasedFXRates }; 53 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Fraction } from 'mathjs'; 2 | export enum currency { 3 | USD = 'USD', 4 | EUR = 'EUR', 5 | GBP = 'GBP', 6 | JPY = 'JPY', 7 | AUD = 'AUD', 8 | CAD = 'CAD', 9 | CHF = 'CHF', 10 | CNY = 'CNY', 11 | SEK = 'SEK', 12 | NZD = 'NZD', 13 | KRW = 'KRW', 14 | SGD = 'SGD', 15 | NOK = 'NOK', 16 | MXN = 'MXN', 17 | INR = 'INR', 18 | RUB = 'RUB', 19 | ZAR = 'ZAR', 20 | BRL = 'BRL', 21 | TWD = 'TWD', 22 | DKK = 'DKK', 23 | PLN = 'PLN', 24 | THB = 'THB', 25 | IDR = 'IDR', 26 | HUF = 'HUF', 27 | CZK = 'CZK', 28 | ILS = 'ILS', 29 | CLP = 'CLP', 30 | PHP = 'PHP', 31 | AED = 'AED', 32 | COP = 'COP', 33 | SAR = 'SAR', 34 | MYR = 'MYR', 35 | RON = 'RON', 36 | KWD = 'KWD', 37 | VND = 'VND', 38 | ARS = 'ARS', 39 | TRY = 'TRY', 40 | HKD = 'HKD', 41 | PKR = 'PKR', 42 | BDT = 'BDT', 43 | LKR = 'LKR', 44 | MOP = 'MOP', 45 | KZT = 'KZT', 46 | TJS = 'TJS', 47 | MNT = 'MNT', 48 | LAK = 'LAK', 49 | IRR = 'IRR', 50 | RMB = CNY, 51 | CNH = 'CNH', // CNY (overseas) 52 | AUX = 'AUX', // Gold 995 53 | AUY = 'AUY', // Gold 999 54 | BND = 'BND', 55 | unknown, 56 | } 57 | 58 | export interface FXRate { 59 | currency: { 60 | from: currency; 61 | to: currency; 62 | }; 63 | rate: { 64 | buy?: { 65 | cash?: Fraction | number; 66 | remit?: Fraction | number; 67 | }; 68 | sell?: { 69 | cash?: Fraction | number; 70 | remit?: Fraction | number; 71 | }; 72 | middle?: Fraction | number; 73 | }; 74 | unit: 1 | 10 | 100 | 10000 | number; 75 | updated: Date; 76 | } 77 | 78 | export interface FXPath { 79 | from: currency; 80 | end: currency; 81 | path: currency[]; 82 | } 83 | 84 | export enum JSONRPCMethods { 85 | instanceInfo = 'instanceInfo', 86 | listCurrencies = 'listCurrencies', 87 | getFXRate = 'getFXRate', 88 | listFXRates = 'listFXRates', 89 | } 90 | -------------------------------------------------------------------------------- /src/FXGetter/psbc.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FXRate, currency } from 'src/types'; 3 | import { parseYYYYMMDDHHmmss } from './ncb.cn'; 4 | 5 | import https from 'https'; 6 | import crypto from 'crypto'; 7 | 8 | const allowPSBCCertificateforNodeJsOptions = { 9 | httpsAgent: new https.Agent({ 10 | // dont vertify sb PSBC SSL Certificate (becuz they don't send full certificate chain now!!!) 11 | // 💩 PSBC 12 | rejectUnauthorized: false, 13 | // allow sb PSBC to use legacy renegotiation 14 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 15 | }), 16 | }; 17 | 18 | const getPSBCFXRates = async () => { 19 | const res = await axios.get( 20 | 'https://s.psbc.com/portal/PsbcService/foreignexchange/curr', 21 | { 22 | ...allowPSBCCertificateforNodeJsOptions, 23 | headers: { 24 | 'User-Agent': 25 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 26 | }, 27 | }, 28 | ); 29 | 30 | const data = JSON.parse( 31 | res.data.replaceAll('empty(', '').replaceAll(')', ''), 32 | ).resultList; 33 | 34 | const answer = data 35 | .filter((k) => k.flag == 2) 36 | .map((fx) => { 37 | return { 38 | currency: { 39 | from: fx.cur as currency.unknown, 40 | to: 'CNY' as currency.CNY, 41 | }, 42 | rate: { 43 | buy: { 44 | remit: fx.fe_buy_prc, 45 | cash: fx.fc_buy_prc, 46 | }, 47 | sell: { 48 | remit: fx.fe_sell_prc, 49 | cash: fx.fe_sell_prc, 50 | }, 51 | middle: fx.mid_prc, 52 | }, 53 | unit: 100, 54 | updated: parseYYYYMMDDHHmmss( 55 | `${fx.effect_date}${fx.effect_time}`, 56 | ), 57 | } as FXRate; 58 | }) 59 | .sort(); 60 | 61 | return answer; 62 | }; 63 | 64 | export default getPSBCFXRates; 65 | -------------------------------------------------------------------------------- /src/FXGetter/hsbc.hk.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { currency, FXRate } from 'src/types'; 4 | 5 | const getHSBCHKFXRates = async (): Promise => { 6 | const req = await axios.get( 7 | `https://rbwm-api.hsbc.com.hk/digital-pws-tools-investments-eapi-prod-proxy/v1/investments/exchange-rate?locale=en_HK`, 8 | { 9 | headers: { 10 | 'User-Agent': 11 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 12 | }, 13 | }, 14 | ); 15 | 16 | const data = req.data.detailRates; 17 | 18 | const answers: FXRate[] = data 19 | .map((k) => { 20 | const answer: FXRate = { 21 | currency: { 22 | from: k.ccy as currency.unknown, 23 | to: 'HKD' as currency.HKD, 24 | }, 25 | rate: { 26 | buy: {}, 27 | sell: {}, 28 | }, 29 | updated: new Date(k.lastUpdateDate), 30 | unit: 1, 31 | }; 32 | 33 | if (k.ttBuyRt) answer.rate.buy.remit = parseFloat(k.ttBuyRt); 34 | if (k.bankBuyRt) answer.rate.buy.cash = parseFloat(k.bankBuyRt); 35 | if (k.ttSelRt) answer.rate.sell.remit = parseFloat(k.ttSelRt); 36 | if (k.bankSellRt) answer.rate.sell.cash = parseFloat(k.bankSellRt); 37 | 38 | if (answer.currency.from == 'CNY') { 39 | const CNHAnswer: FXRate = { 40 | ...answer, 41 | currency: { 42 | ...answer.currency, 43 | from: 'CNH' as currency.CNH, 44 | }, 45 | rate: { 46 | buy: { ...answer.rate.buy }, 47 | sell: { ...answer.rate.sell }, 48 | }, 49 | }; 50 | 51 | console.log(answer, CNHAnswer); 52 | 53 | return [answer, CNHAnswer]; 54 | } else return answer; 55 | }) 56 | .flat() 57 | .sort(); 58 | 59 | return answers; 60 | }; 61 | 62 | export default getHSBCHKFXRates; 63 | -------------------------------------------------------------------------------- /src/FXGetter/ncb.hk.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { FXRate, currency } from 'src/types'; 4 | 5 | const currencyMapping = { 6 | '156': 'CNY' as currency.CNY, 7 | A04: 'CNH' as currency.CNH, 8 | '840': 'USD' as currency.USD, 9 | '826': 'GBP' as currency.GBP, 10 | '392': 'JPY' as currency.JPY, 11 | '036': 'AUD' as currency.AUD, 12 | '554': 'NZD' as currency.NZD, 13 | '124': 'CAD' as currency.CAD, 14 | '978': 'EUR' as currency.EUR, 15 | '756': 'CHF' as currency.CHF, 16 | '208': 'DKK' as currency.DKK, 17 | '578': 'NOK' as currency.NOK, 18 | '752': 'SEK' as currency.SEK, 19 | '702': 'SGD' as currency.SGD, 20 | '764': 'THB' as currency.THB, 21 | }; 22 | 23 | const getNCBHKFXRates = async (): Promise => { 24 | const res = await axios.post( 25 | 'https://www.ncb.com.hk/api/precious/findConversionRateAll', 26 | { 27 | headers: { 28 | language: 'en', 29 | 'User-Agent': 30 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 31 | }, 32 | body: { 33 | language: 3, 34 | custType: 1, 35 | }, 36 | }, 37 | ); 38 | 39 | return res.data.data.resultList 40 | .map((fx) => { 41 | const currencyName = currencyMapping[fx.currency]; 42 | 43 | const buy = fx.outNum < fx.inNum ? fx.outNum : fx.inNum; 44 | const sell = fx.outNum < fx.inNum ? fx.inNum : fx.outNum; 45 | 46 | return { 47 | currency: { 48 | to: currencyName as unknown as currency.unknown, 49 | from: 'HKD' as currency.HKD, 50 | }, 51 | rate: { 52 | buy: { 53 | remit: buy, 54 | cash: buy, 55 | }, 56 | sell: { 57 | remit: sell, 58 | cash: sell, 59 | }, 60 | }, 61 | unit: 100, 62 | updated: new Date(fx.createTime + ' UTC+8'), 63 | }; 64 | }) 65 | .sort(); 66 | }; 67 | 68 | export default getNCBHKFXRates; 69 | -------------------------------------------------------------------------------- /src/FXGetter/wise.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { FXRate, currency } from 'src/types'; 4 | import fxmManager from '../fxmManager'; 5 | 6 | const getWiseFXRates = ( 7 | isInSandbox: boolean = false, 8 | useTokenInWeb: boolean = true, 9 | WiseToken: string, 10 | ): ((fxmManager?: fxmManager) => Promise) => { 11 | let endPoint = 'https://api.wise.com/v1/rates'; 12 | if (isInSandbox) { 13 | endPoint = 'https://api.sandbox.transferwise.tech/v1/rates'; 14 | } 15 | 16 | return async (fxmManager?: fxmManager): Promise => { 17 | console.log(isInSandbox, useTokenInWeb, WiseToken); 18 | if (fxmManager && isInSandbox) 19 | fxmManager.log('Getting Wise FX Rates in sandbox mode.'); 20 | else if (fxmManager) 21 | fxmManager.log('Getting Wise FX Rates in production mode.'); 22 | 23 | const response = await axios.get(endPoint, { 24 | headers: { 25 | Authorization: !useTokenInWeb 26 | ? `Bearer ${WiseToken}` 27 | : 'Basic OGNhN2FlMjUtOTNjNS00MmFlLThhYjQtMzlkZTFlOTQzZDEwOjliN2UzNmZkLWRjYjgtNDEwZS1hYzc3LTQ5NGRmYmEyZGJjZA==', 28 | }, 29 | }); 30 | 31 | const rates: FXRate[] = []; 32 | const data: [ 33 | { 34 | rate: string; 35 | source: currency; 36 | target: currency; 37 | time: string; 38 | }, 39 | ] = response.data; 40 | 41 | for (const rate of data) { 42 | rate.source = 43 | rate.source === 'CNY' ? ('CNH' as currency.CNH) : rate.source; 44 | rate.target = 45 | rate.target === 'CNY' ? ('CNH' as currency.CNH) : rate.target; 46 | 47 | rates.push({ 48 | currency: { 49 | from: rate.source as currency.unknown, 50 | to: rate.target as currency.unknown, 51 | }, 52 | rate: { 53 | middle: parseFloat(rate.rate), 54 | }, 55 | unit: 1, 56 | updated: new Date(rate.time), 57 | }); 58 | } 59 | 60 | return rates.sort(); 61 | }; 62 | }; 63 | 64 | export default getWiseFXRates; 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxrate", 3 | "version": "0.0.3", 4 | "license": "SEE LICENSE IN LICENSE", 5 | "author": "Bo Xu (https://186526.xyz/)", 6 | "dependencies": { 7 | "axios": "^1.7.4", 8 | "cheerio": "^1.0.0-rc.12", 9 | "dayjs": "^1.11.10", 10 | "es-main": "^1.3.0", 11 | "fast-xml-parser": "^4.4.1", 12 | "feed": "^4.2.2", 13 | "handlers.js": "0.1.3-3", 14 | "handlers.js-jsonrpc": "0.0.3", 15 | "lru-cache": "^10.2.0", 16 | "mathjs": "^12.3.2", 17 | "sync-request": "^6.1.0", 18 | "typescript": "^5.5.4" 19 | }, 20 | "devDependencies": { 21 | "@dotenvx/dotenvx": "^1.6.4", 22 | "@eslint/js": "^9.8.0", 23 | "@types/jest": "^29.5.12", 24 | "@types/node": "^20.11.17", 25 | "@types/tape": "^5.6.4", 26 | "@typescript-eslint/eslint-plugin": "^7.0.1", 27 | "@typescript-eslint/parser": "^7.1.0", 28 | "esbuild": "^0.23.0", 29 | "eslint": "9.x", 30 | "eslint-config-prettier": "^9.1.0", 31 | "eslint-plugin-prettier": "^5.1.3", 32 | "globals": "^15.8.0", 33 | "husky": "^9.1.4", 34 | "jest": "^29.7.0", 35 | "prettier": "^3.2.5", 36 | "ts-jest": "^29.2.4", 37 | "ts-node": "^10.9.2", 38 | "tsc-alias": "^1.8.10", 39 | "tslib": "^2.6.2", 40 | "tsx": "^4.7.1", 41 | "typescript-eslint": "^8.0.0" 42 | }, 43 | "type": "module", 44 | "scripts": { 45 | "postinstall": "husky", 46 | "format": "prettier --write \"**/*.{ts,json,md}\" ", 47 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 48 | "build": "yarn clean && esbuild src/index.ts --minify --entry-names=[name] --format=cjs --platform=node --bundle --outdir=dist --external:sync-request --define:\"globalThis.esBuilt=true\" --define:globalThis.GITBUILD=\\\"$(git rev-parse --short HEAD)\\\" --define:globalThis.BUILDTIME=\\\"$(date -Iseconds)\\\" && mv dist/index.js dist/index.cjs", 49 | "clean": "rm -rf dist/[!readme.md]*", 50 | "dev": "dotenvx run -- tsx watch src/index.ts", 51 | "dev:production": "dotenvx run --env-file=.env.production.local --env-file=.env -- tsx watch src/index.ts", 52 | "start": "yarn build && node dist/index.cjs", 53 | "test": "NODE_OPTIONS=--experimental-vm-modules jest", 54 | "test:coverage": "yarn test --collectCoverage" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/FXGetter/ncb.cn.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { FXRate, currency } from 'src/types'; 4 | 5 | export function parseYYYYMMDDHHmmss(dateStr) { 6 | const year = dateStr.substring(0, 4); 7 | const month = dateStr.substring(4, 6); 8 | const day = dateStr.substring(6, 8); 9 | const hour = dateStr.substring(8, 10); 10 | const minute = dateStr.substring(10, 12); 11 | const second = dateStr.substring(12, 14); 12 | 13 | return new Date( 14 | `${year}/${month}/${day} ${hour}:${minute}:${second} UTC+8`, 15 | ); 16 | } 17 | 18 | const getNCBCNFXRates = async (): Promise => { 19 | const res = await axios.post( 20 | 'https://ibs.ncbchina.cn/NCB/mForeignExchangePriceQuery', 21 | { ccyPair: '', bsnsTp: '1' }, 22 | { 23 | headers: { 24 | 'User-Agent': 25 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 26 | }, 27 | }, 28 | ); 29 | 30 | const data: { 31 | bsnsTp: 'SETFORE_EX'; 32 | ccyPair: string; 33 | cstCashBuyPrc: number; 34 | cstCashMdlPrc: number; 35 | cstCashSellPrc: number; 36 | cstExgBuyPrc: number; 37 | cstExgMdlPrc: number; 38 | cstExgSellPrc: number; 39 | mktQtnDt: string; 40 | mktQtnSt: string; 41 | mktQtnTm: string; 42 | qtnUnit: null; 43 | }[] = res.data.mktQtnInfoArrList; 44 | 45 | const FXRates: FXRate[] = []; 46 | 47 | data.forEach((fx) => { 48 | if (fx.bsnsTp !== 'SETFORE_EX') return; 49 | 50 | const currencyName = fx.ccyPair.split('/').filter((k) => k != 'CNY')[0]; 51 | 52 | FXRates.push({ 53 | currency: { 54 | from: currencyName as unknown as currency.unknown, 55 | to: 'CNY' as currency.CNY, 56 | }, 57 | rate: { 58 | sell: { 59 | remit: fx.cstExgBuyPrc, 60 | cash: fx.cstCashBuyPrc, 61 | }, 62 | buy: { 63 | remit: fx.cstExgSellPrc, 64 | cash: fx.cstCashSellPrc, 65 | }, 66 | middle: fx.cstExgMdlPrc, 67 | }, 68 | unit: currencyName === 'JPY' ? 100 : 1, 69 | updated: parseYYYYMMDDHHmmss( 70 | `${fx.mktQtnDt}${fx.mktQtnTm.padStart(6, '0')}`, 71 | ), 72 | }); 73 | }); 74 | 75 | return FXRates; 76 | }; 77 | 78 | export default getNCBCNFXRates; 79 | -------------------------------------------------------------------------------- /src/FXGetter/pab.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { FXRate, currency } from 'src/types'; 4 | 5 | export interface PABResponse { 6 | data: { 7 | count: number; // length of exchangeList 8 | exchangeList: { 9 | basePrice: number; // 100 unit of foreign currency middle rate to CNY 10 | buyPrice: number; // 100 unit of foreign currency buy rate to CNY 11 | cashBuyPrice: number; // 100 unit of foreign currency cash buy rate to CNY 12 | currName: string; // like '美元' 13 | currType: currency; // like 'USD' 14 | exchangeDate: string; // like '2021-08-17' 15 | insertTime: string; // like '2021-08-17 10:00:00' 16 | movePrice: number; // unknown 17 | payPrice: number; // unknown 18 | rmbRate: number; // unknown 19 | sellPrice: number; // 100 unit of foreign currency sell rate to CNY 20 | usdRate: 0; // unknown 21 | }[]; 22 | }; 23 | responseCode: string; // like 000000, 6 digits 24 | responseMsg: string; // like '成功' 25 | } 26 | 27 | const getPABFXRates = async (): Promise => { 28 | const req = await axios.get( 29 | 'https://bank.pingan.com.cn/rmb/account/cmp/cust/acct/forex/exchange/qryFoexPriceExchangeList.do?pageIndex=1&pageSize=100&realFlag=1¤cyCode=&exchangeDate=&languageCode=zh_CN&access_source=PC', 30 | { 31 | headers: { 32 | 'User-Agent': 33 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 34 | }, 35 | }, 36 | ); 37 | 38 | const data: PABResponse = req.data; 39 | 40 | return data.data.exchangeList 41 | .map((rate) => { 42 | return { 43 | currency: { 44 | from: rate.currType, 45 | to: 'CNY' as currency.CNY, 46 | }, 47 | rate: { 48 | buy: { 49 | cash: rate.cashBuyPrice, 50 | remit: rate.buyPrice, 51 | }, 52 | sell: { 53 | cash: rate.sellPrice, 54 | remit: rate.sellPrice, 55 | }, 56 | middle: rate.basePrice, 57 | }, 58 | unit: 100, 59 | updated: new Date(rate.insertTime + ' GMT+0800'), 60 | } as FXRate; 61 | }) 62 | .sort(); 63 | }; 64 | 65 | export default getPABFXRates; 66 | -------------------------------------------------------------------------------- /src/FXGetter/xib.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { currency, FXRate } from 'src/types'; 3 | 4 | import { parseYYYYMMDDHHmmss } from './ncb.cn'; 5 | 6 | import crypto from 'crypto'; 7 | import https from 'https'; 8 | 9 | const allowLegacyRenegotiationforNodeJsOptions = { 10 | httpsAgent: new https.Agent({ 11 | // allow sb ABC to use legacy renegotiation 12 | // 💩 ABC 13 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 14 | }), 15 | }; 16 | 17 | const getXIBFXRates = async (): Promise => { 18 | const req = await axios.post( 19 | 'https://ifsp.xib.com.cn/ifsptsi/api/ITSI125005', 20 | { 21 | ccyPairCode: '', 22 | transactionType: '0', 23 | header: { 24 | appId: 'XEIP', 25 | locale: 'zh_CN', 26 | termType: '', 27 | termNo: '', 28 | termMac: '', 29 | appVersion: '', 30 | }, 31 | }, 32 | { 33 | headers: { 34 | 'User-Agent': 35 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 36 | }, 37 | ...allowLegacyRenegotiationforNodeJsOptions, 38 | }, 39 | ); 40 | 41 | const data: { 42 | baseRate: number; 43 | cashBuyPrice: number; 44 | cashSellPrice: number; 45 | companyType: 'XIB'; 46 | currency: string; 47 | currencyBuyPrice: number; 48 | currencySellPrice: number; 49 | squareBuyRate: number; 50 | squareSellRate: number; 51 | term: null; 52 | updateDate: string; 53 | updateTime: string; 54 | }[] = req.data.rateList; 55 | 56 | const FXRates: FXRate[] = []; 57 | 58 | data.forEach((fx) => { 59 | FXRates.push({ 60 | currency: { 61 | from: fx.currency as unknown as currency.unknown, 62 | to: 'CNY' as currency.CNY, 63 | }, 64 | rate: { 65 | buy: { 66 | remit: fx.currencyBuyPrice, 67 | cash: fx.cashBuyPrice, 68 | }, 69 | sell: { 70 | remit: fx.currencySellPrice, 71 | cash: fx.cashSellPrice, 72 | }, 73 | }, 74 | unit: 100, 75 | updated: parseYYYYMMDDHHmmss(`${fx.updateDate}${fx.updateTime}`), 76 | }); 77 | }); 78 | 79 | return FXRates; 80 | }; 81 | 82 | export default getXIBFXRates; 83 | -------------------------------------------------------------------------------- /src/FXGetter/spdb.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents exchange rate and pricing information returned from the SPDB remote service. 3 | * 4 | * This interface models a flat (string-valued) payload typically obtained from a SOAP/HTTP response. 5 | * All fields are represented as strings in the original payload; numeric values may be formatted with 6 | * fixed decimals and should be parsed to numbers by consumers when needed. 7 | * 8 | * @remarks 9 | * Keep in mind: 10 | * - Timestamp fields use the source format (e.g. "YYYY.MM.DD HH:mm:ss") and may require parsing. 11 | * - Price and rate fields are often scaled (e.g. "100.000000") and may represent per-unit or per-100 units, 12 | * depending on ExgRtUnt. 13 | * 14 | * @property RET - Raw response fragment (often contains the beginning of a SOAP/XML envelope). 15 | * @property ReturnCode - Service return/response code (empty string when no code is present). 16 | * @property UnchSellPrc - "Unchanged" sell price (string-formatted decimal). 17 | * @property docid - Document identifier for the record. 18 | * @property AnlSetlExgRt - Analytical settlement exchange rate (string-formatted decimal). 19 | * @property SellPrc - Sell price (string-formatted decimal). 20 | * @property CurrencyId - Currency identifier/code (e.g. "01"). 21 | * @property CurrencyName - Human readable currency name like '美元 USD'. 22 | * @property CashBuyPrc - Cash buy price (string-formatted decimal). 23 | * @property CREATE_DATE - Creation date/time as provided by the source (e.g. "2025.10.20 22:30:16"). 24 | * @property BuyPrc - Buy price (string-formatted decimal). 25 | * @property MdlPrc - Middle/median price or model price (string-formatted decimal). 26 | * @property CashSellPrc - Cash sell price (string-formatted decimal). 27 | * @property UnchBuyPrc - "Unchanged" buy price (string-formatted decimal). 28 | * @property USDCnvrPrc - USD conversion price (string-formatted decimal; may be used to derive cross-rates). 29 | * @property ctime - Processing or cache time indicator (source-specific meaning; often numeric string). 30 | * @property state - State or status code for the record (source-specific string). 31 | * @property ExgRtUnt - Exchange rate unit (e.g. "100" means rates are per 100 units). 32 | * @property EurSetlPrc - EUR settlement price (string-formatted decimal). 33 | * 34 | */ 35 | 36 | export interface SPDBFXReqInfo { 37 | RET: string; 38 | ReturnCode: string; 39 | UnchSellPrc: string; 40 | docid: string; 41 | AnlSetlExgRt: string; 42 | SellPrc: string; 43 | CurrencyId: string; 44 | CurrencyName: string; 45 | CashBuyPrc: string; 46 | CREATE_DATE: string; 47 | BuyPrc: string; 48 | MdlPrc: string; 49 | CashSellPrc: string; 50 | UnchBuyPrc: string; 51 | USDCnvrPrc: string; 52 | ctime: string; 53 | state: string; 54 | ExgRtUnt: string; 55 | EurSetlPrc: string; 56 | } 57 | -------------------------------------------------------------------------------- /src/FXGetter/citic.cn.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { currency, FXRate } from 'src/types'; 3 | 4 | interface citicBankResponse { 5 | quotePriceDate: string; 6 | quotePriceTime: string; 7 | 8 | curName: string; 9 | curCode: string; 10 | 11 | totalPidPrice: string; 12 | totalSellPrice: string; 13 | 14 | cstexcBuyPrice: string; 15 | cstexcSellPrice: string; 16 | 17 | cstpurBuyPrice: string; 18 | cstpurSellPrice: string; 19 | 20 | midPrice: string; 21 | } 22 | 23 | const currencyMap = { 24 | '027001': 'JPY' as currency.JPY, 25 | '012001': 'GBP' as currency.GBP, 26 | '023001': 'NOK' as currency.NOK, 27 | '051001': 'EUR' as currency.EUR, 28 | '014001': 'USD' as currency.USD, 29 | '028001': 'CAD' as currency.CAD, 30 | '032001': 'MYR' as currency.MYR, 31 | '038001': 'THB' as currency.THB, 32 | '081001': 'MOP' as currency.MOP, 33 | '018001': 'SGD' as currency.SGD, 34 | '065001': 'SAR' as currency.SAR, 35 | '021001': 'SEK' as currency.SEK, 36 | '015001': 'CHF' as currency.CHF, 37 | '062001': 'NZD' as currency.NZD, 38 | '029001': 'AUD' as currency.AUD, 39 | '022001': 'DKK' as currency.DKK, 40 | '031001': 'KZT' as currency.KZT, 41 | '013001': 'HKD' as currency.HKD, 42 | }; 43 | 44 | const getCITICCNFXRates = async (): Promise => { 45 | const req = await axios.get( 46 | `https://etrade.citicbank.com/portalweb/cms/getForeignExchRate.htm?callback=JSON.stringify`, 47 | { 48 | headers: { 49 | 'User-Agent': 50 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 51 | }, 52 | }, 53 | ); 54 | 55 | const data: citicBankResponse[] = JSON.parse([eval][0](req.data)).content 56 | .resultList; 57 | 58 | const answer: FXRate[] = []; 59 | 60 | data.forEach((k) => { 61 | if (!Object.keys(currencyMap).includes(k.curCode)) { 62 | return; 63 | } 64 | 65 | answer.push({ 66 | currency: { 67 | from: currencyMap[k.curCode] as currency.unknown, 68 | to: 'CNY' as currency.CNY, 69 | }, 70 | rate: { 71 | buy: { 72 | remit: parseFloat(k.cstexcBuyPrice), 73 | cash: parseFloat(k.cstexcBuyPrice), 74 | }, 75 | sell: { 76 | cash: parseFloat(k.cstexcSellPrice), 77 | remit: parseFloat(k.cstexcSellPrice), 78 | }, 79 | middle: parseFloat(k.midPrice), 80 | }, 81 | unit: 100, 82 | updated: new Date( 83 | `${k.quotePriceDate.replace('年', '-').replace('月', '-').replace('日', '')} ${k.quotePriceTime} UTC+8`, 84 | ), 85 | }); 86 | }); 87 | 88 | return answer.sort(); 89 | }; 90 | 91 | export default getCITICCNFXRates; 92 | -------------------------------------------------------------------------------- /src/FXGetter/bocom.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import crypto from 'crypto'; 4 | import https from 'https'; 5 | 6 | import cheerio from 'cheerio'; 7 | 8 | import { currency, FXRate } from 'src/types'; 9 | 10 | /** 11 | * Handle this problem with Node 18 12 | * write EPROTO B8150000:error:0A000152:SSL routines:final_renegotiate:unsafe legacy renegotiation disabled 13 | **/ 14 | const allowLegacyRenegotiationforNodeJsOptions = { 15 | httpsAgent: new https.Agent({ 16 | // allow sb ABC to use legacy renegotiation 17 | // 💩 ABC 18 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 19 | }), 20 | }; 21 | 22 | const getBOCOMFXRates = async (): Promise => { 23 | const req = await axios.get( 24 | 'http://www.bankcomm.com/SITE/queryExchangeResult.do', 25 | { 26 | ...allowLegacyRenegotiationforNodeJsOptions, 27 | headers: { 28 | 'User-Agent': 29 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 30 | }, 31 | }, 32 | ); 33 | 34 | const data = req.data['RSP_BODY'].fileContent; 35 | const $ = cheerio.load( 36 | '' + 37 | data + 38 | '
', 39 | ); 40 | const updatedTime = new Date( 41 | $('td[align="left"]').text().split(':')[1] + ' UTC+8', 42 | ); 43 | 44 | return $('tr.data') 45 | .toArray() 46 | .map((el) => { 47 | const result: FXRate = { 48 | currency: { 49 | from: $($(el).children()[0]) 50 | .text() 51 | .split('(')[1] 52 | .split('/')[0] as unknown as currency.unknown, 53 | to: 'CNY' as currency.CNY, 54 | }, 55 | rate: { 56 | buy: {}, 57 | sell: {}, 58 | }, 59 | unit: parseInt($($(el).children()[1]).text()), 60 | updated: updatedTime, 61 | }; 62 | 63 | if ($($(el).children()[2]).text() !== '-') 64 | result.rate.buy.remit = parseFloat( 65 | $($(el).children()[2]).text(), 66 | ); 67 | if ($($(el).children()[3]).text() !== '-') 68 | result.rate.sell.remit = parseFloat( 69 | $($(el).children()[3]).text(), 70 | ); 71 | if ($($(el).children()[4]).text() !== '-') 72 | result.rate.buy.cash = parseFloat( 73 | $($(el).children()[4]).text(), 74 | ); 75 | if ($($(el).children()[5]).text() !== '-') 76 | result.rate.sell.cash = parseFloat( 77 | $($(el).children()[5]).text(), 78 | ); 79 | 80 | return result; 81 | }) 82 | .sort(); 83 | }; 84 | 85 | export default getBOCOMFXRates; 86 | -------------------------------------------------------------------------------- /src/FXGetter/ceb.ts: -------------------------------------------------------------------------------- 1 | import { FXRate, currency } from 'src/types'; 2 | import axios from 'axios'; 3 | import cheerio from 'cheerio'; 4 | 5 | import crypto from 'crypto'; 6 | import https from 'https'; 7 | 8 | /** 9 | * Handle this problem with Node 18 10 | * write EPROTO B8150000:error:0A000152:SSL routines:final_renegotiate:unsafe legacy renegotiation disabled 11 | **/ 12 | const allowLegacyRenegotiationforNodeJsOptions = { 13 | httpsAgent: new https.Agent({ 14 | // allow sb CIB to use legacy renegotiation 15 | // 💩 CIB 16 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 17 | }), 18 | }; 19 | 20 | export const enName: Record = { 21 | '美元(USD)': 'USD' as currency.USD, 22 | '英镑(GBP)': 'GBP' as currency.GBP, 23 | '港币(HKD)': 'HKD' as currency.HKD, 24 | '瑞士法郎(CHF)': 'CHF' as currency.CHF, 25 | 瑞典克朗: 'SEK' as currency.SEK, 26 | 丹麦克朗: 'DKK' as currency.DKK, 27 | 挪威克朗: 'NOK' as currency.NOK, 28 | '日元(JPY)': 'JPY' as currency.JPY, 29 | '加拿大元(CAD)': 'CAD' as currency.CAD, 30 | '澳大利亚元(AUD)': 'AUD' as currency.AUD, 31 | '新加坡元(SGD)': 'SGD' as currency.SGD, 32 | '欧元(EUR)': 'EUR' as currency.EUR, 33 | '澳门元(MOP)': 'MOP' as currency.MOP, 34 | '泰国铢(THB)': 'THB' as currency.THB, 35 | 新台币: 'TWD' as currency.TWD, 36 | '新西兰元(NZD)': 'NZD' as currency.NZD, 37 | 韩元: 'KRW' as currency.KRW, 38 | }; 39 | 40 | const getCEBFXRates = async (): Promise => { 41 | const res = await axios.get( 42 | 'https://www.cebbank.com/eportal/ui?pageId=477257', 43 | { 44 | ...allowLegacyRenegotiationforNodeJsOptions, 45 | headers: { 46 | 'User-Agent': 47 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 48 | }, 49 | }, 50 | ); 51 | 52 | const $ = cheerio.load(res.data); 53 | 54 | const items: FXRate[] = $('.lczj_box tbody tr') 55 | .map((i, e) => { 56 | if (i < 2) { 57 | return null; 58 | } 59 | const c = cheerio.load(e, { decodeEntities: false }); 60 | return { 61 | currency: { 62 | from: enName[c('td:nth-child(1)').text()], 63 | to: 'CNY' as currency.CNY, 64 | }, 65 | rate: { 66 | sell: { 67 | remit: parseFloat(c('td:nth-child(2)').text()), 68 | cash: parseFloat(c('td:nth-child(3)').text()), 69 | }, 70 | buy: { 71 | remit: parseFloat(c('td:nth-child(4)').text()), 72 | cash: parseFloat(c('td:nth-child(5)').text()), 73 | }, 74 | }, 75 | unit: 100, 76 | updated: new Date( 77 | $('#t_id span').text().substring(5) + ' UTC+8', 78 | ), 79 | }; 80 | }) 81 | .get(); 82 | 83 | return items.filter((i) => i !== null).sort() as FXRate[]; 84 | }; 85 | 86 | export default getCEBFXRates; 87 | -------------------------------------------------------------------------------- /src/FXGetter/abc.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import crypto from 'crypto'; 4 | import https from 'https'; 5 | 6 | import { currency, FXRate } from 'src/types'; 7 | 8 | /** 9 | * Handle this problem with Node 18 10 | * write EPROTO B8150000:error:0A000152:SSL routines:final_renegotiate:unsafe legacy renegotiation disabled 11 | **/ 12 | const allowLegacyRenegotiationforNodeJsOptions = { 13 | httpsAgent: new https.Agent({ 14 | // allow sb ABC to use legacy renegotiation 15 | // 💩 ABC 16 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 17 | }), 18 | }; 19 | 20 | const currencyMap = { 21 | '14': { name: 'USD' as currency.USD }, 22 | '13': { name: 'HKD' as currency.HKD }, 23 | '38': { name: 'EUR' as currency.EUR }, 24 | '27': { name: 'JPY' as currency.JPY }, 25 | '12': { name: 'GBP' as currency.GBP }, 26 | '29': { name: 'AUD' as currency.AUD }, 27 | '28': { name: 'CAD' as currency.CAD }, 28 | '15': { name: 'CHF' as currency.CHF }, 29 | '88': { name: 'KRW' as currency.KRW }, 30 | '81': { name: 'MOP' as currency.MOP }, 31 | '18': { name: 'SGD' as currency.SGD }, 32 | '84': { name: 'THB' as currency.THB }, 33 | '22': { name: 'DKK' as currency.DKK }, 34 | '23': { name: 'NOK' as currency.NOK }, 35 | '21': { name: 'SEK' as currency.SEK }, 36 | '79': { name: 'TJS' as currency.TJS }, 37 | '64': { name: 'VND' as currency.VND }, 38 | '68': { name: 'KZT' as currency.KZT }, 39 | '70': { name: 'RUB' as currency.RUB }, 40 | '71': { name: 'ZAR' as currency.ZAR }, 41 | '73': { name: 'MNT' as currency.MNT }, 42 | '74': { name: 'LAK' as currency.LAK }, 43 | '78': { name: 'AED' as currency.AED }, 44 | '87': { name: 'NZD' as currency.NZD }, 45 | }; 46 | 47 | const getABCFXRates = async (): Promise => { 48 | const req = await axios.get( 49 | 'https://ewealth.abchina.com/app/data/api/DataService/ExchangeRateV2', 50 | { 51 | ...allowLegacyRenegotiationforNodeJsOptions, 52 | headers: { 53 | 'User-Agent': 54 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 55 | }, 56 | }, 57 | ); 58 | 59 | const data = req.data.Data.Table; 60 | 61 | return data 62 | .map((d: any) => { 63 | return { 64 | currency: { 65 | from: currencyMap[d.CurrId].name, 66 | to: 'CNY' as currency.CNY, 67 | }, 68 | rate: { 69 | buy: { 70 | remit: parseFloat(d.BuyingPrice), 71 | cash: parseFloat(d.CashBuyingPrice), 72 | }, 73 | sell: { 74 | remit: parseFloat(d.SellPrice), 75 | cash: parseFloat(d.SellPrice), 76 | }, 77 | middle: parseFloat(d.BenchMarkPrice), 78 | }, 79 | updated: new Date(d.PublishTime), 80 | unit: 100, 81 | } as FXRate; 82 | }) 83 | .sort(); 84 | }; 85 | 86 | export default getABCFXRates; 87 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | # 2 | name: Create and publish a Docker image 3 | 4 | # Configures this workflow to run every time a change is pushed to the branch called `release`. 5 | on: 6 | release: 7 | types: [published] 8 | push: 9 | branches: 10 | - release 11 | - main 12 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 13 | env: 14 | REGISTRY: ghcr.io 15 | IMAGE_NAME: ${{ github.repository }} 16 | 17 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 18 | jobs: 19 | build-and-push-image: 20 | runs-on: ubuntu-latest 21 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 22 | permissions: 23 | contents: read 24 | packages: write 25 | attestations: write 26 | id-token: write 27 | # 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 32 | - name: Log in to the Container registry 33 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 45 | # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. 46 | # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 47 | - name: Build and push Docker image 48 | id: push 49 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 50 | with: 51 | context: . 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | 56 | # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." 57 | - name: Generate artifact attestation 58 | uses: actions/attest-build-provenance@v1 59 | with: 60 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 61 | subject-digest: ${{ steps.push.outputs.digest }} 62 | push-to-registry: true 63 | -------------------------------------------------------------------------------- /src/FXGetter/pboc.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FXRate, currency } from 'src/types'; 3 | import cheerio from 'cheerio'; 4 | 5 | const currencyMap = { 6 | 美元: 'USD', 7 | 欧元: 'EUR', 8 | 日元: 'JPY', 9 | 港元: 'HKD', 10 | 英镑: 'GBP', 11 | 澳元: 'AUD', 12 | 新西兰元: 'NZD', 13 | 新加坡元: 'SGD', 14 | 瑞士法郎: 'CHF', 15 | 加元: 'CAD', 16 | 澳门元: 'MOP', 17 | 林吉特: 'MYR', 18 | 卢布: 'RUB', 19 | 兰特: 'ZAR', 20 | 韩元: 'KRW', 21 | 迪拉姆: 'AED', 22 | 里亚尔: 'SAR', 23 | 福林: 'HUF', 24 | 兹罗提: 'PLN', 25 | 丹麦克朗: 'DKK', 26 | 瑞典克朗: 'SEK', 27 | 挪威克朗: 'NOK', 28 | 里拉: 'TRY', 29 | 比索: 'MXN', 30 | 泰铢: 'THB', 31 | }; 32 | 33 | const undirectPrice: currency[] = [ 34 | 'MOP' as currency.MOP, 35 | 'MYR' as currency.MYR, 36 | 'RUB' as currency.RUB, 37 | 'ZAR' as currency.ZAR, 38 | 'KRW' as currency.KRW, 39 | 'AED' as currency.AED, 40 | 'SAR' as currency.SAR, 41 | 'HUF' as currency.HUF, 42 | 'PLN' as currency.PLN, 43 | 'DKK' as currency.DKK, 44 | 'SEK' as currency.SEK, 45 | 'NOK' as currency.NOK, 46 | 'TRY' as currency.TRY, 47 | 'MXN' as currency.MXN, 48 | 'THB' as currency.THB, 49 | ]; 50 | 51 | const getPBOCFXRates = async (): Promise => { 52 | const res = await axios.get( 53 | 'http://www.safe.gov.cn/AppStructured/hlw/RMBQuery.do', 54 | { 55 | headers: { 56 | 'User-Agent': 57 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 58 | }, 59 | }, 60 | ); 61 | 62 | const $ = cheerio.load(res.data); 63 | const table = $('table#InfoTable').children()[0]; 64 | 65 | return table.children 66 | .slice(1) 67 | .map((el) => { 68 | const row = $(el); 69 | 70 | const updateTime = new Date( 71 | $(row.children()[0]).text() + ' 00:00 UTC+8', 72 | ); 73 | 74 | return row 75 | .children() 76 | .slice(1) 77 | .toArray() 78 | .map((thisEL, index): FXRate => { 79 | const anz = { 80 | currency: { 81 | from: 'unknown' as unknown as currency, 82 | to: 'unknown' as unknown as currency, 83 | }, 84 | rate: { 85 | middle: parseFloat($(thisEL).text()), 86 | }, 87 | updated: updateTime, 88 | unit: 100, 89 | }; 90 | 91 | const currencyZHName = $( 92 | $(table.children[0]).children()[index + 1], 93 | ) 94 | .text() 95 | .trim(); 96 | 97 | if (undirectPrice.includes(currencyMap[currencyZHName])) { 98 | anz.currency = { 99 | from: 'CNY' as currency.CNY, 100 | to: currencyMap[currencyZHName] as currency.unknown, 101 | }; 102 | } else { 103 | anz.currency = { 104 | from: currencyMap[ 105 | currencyZHName 106 | ] as currency.unknown, 107 | to: 'CNY' as currency.CNY, 108 | }; 109 | } 110 | return anz; 111 | }); 112 | }) 113 | .flat() 114 | .sort(); 115 | }; 116 | 117 | export default getPBOCFXRates; 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/linux,visualstudiocode,node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=linux,visualstudiocode,node 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### Node ### 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | lerna-debug.log* 27 | .pnpm-debug.log* 28 | 29 | # Diagnostic reports (https://nodejs.org/api/report.html) 30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 31 | 32 | # Runtime data 33 | pids 34 | *.pid 35 | *.seed 36 | *.pid.lock 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | *.lcov 44 | 45 | # nyc test coverage 46 | .nyc_output 47 | 48 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 49 | .grunt 50 | 51 | # Bower dependency directory (https://bower.io/) 52 | bower_components 53 | 54 | # node-waf configuration 55 | .lock-wscript 56 | 57 | # Compiled binary addons (https://nodejs.org/api/addons.html) 58 | build/Release 59 | 60 | # Dependency directories 61 | node_modules/ 62 | jspm_packages/ 63 | 64 | # Snowpack dependency directory (https://snowpack.dev/) 65 | web_modules/ 66 | 67 | # TypeScript cache 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | .npm 72 | 73 | # Optional eslint cache 74 | .eslintcache 75 | 76 | # Optional stylelint cache 77 | .stylelintcache 78 | 79 | # Microbundle cache 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | .node_repl_history 87 | 88 | # Output of 'npm pack' 89 | *.tgz 90 | 91 | # Yarn Integrity file 92 | .yarn-integrity 93 | 94 | # dotenv environment variable files 95 | .env 96 | .env.development.local 97 | .env.test.local 98 | .env.production.local 99 | .env.local 100 | 101 | # parcel-bundler cache (https://parceljs.org/) 102 | .cache 103 | .parcel-cache 104 | 105 | # Next.js build output 106 | .next 107 | out 108 | 109 | # Nuxt.js build / generate output 110 | .nuxt 111 | 112 | # Gatsby files 113 | .cache/ 114 | # Comment in the public line in if your project uses Gatsby and not Next.js 115 | # https://nextjs.org/blog/next-9-1#public-directory-support 116 | # public 117 | 118 | # vuepress build output 119 | .vuepress/dist 120 | 121 | # vuepress v2.x temp and cache directory 122 | .temp 123 | 124 | # Docusaurus cache and generated files 125 | .docusaurus 126 | 127 | # Serverless directories 128 | .serverless/ 129 | 130 | # FuseBox cache 131 | .fusebox/ 132 | 133 | # DynamoDB Local files 134 | .dynamodb/ 135 | 136 | # TernJS port file 137 | .tern-port 138 | 139 | # Stores VSCode versions used for testing VSCode extensions 140 | .vscode-test 141 | 142 | # yarn v2 143 | .yarn/cache 144 | .yarn/unplugged 145 | .yarn/build-state.yml 146 | .yarn/install-state.gz 147 | .pnp.* 148 | 149 | ### Node Patch ### 150 | # Serverless Webpack directories 151 | .webpack/ 152 | 153 | # Optional stylelint cache 154 | 155 | # SvelteKit build / generate output 156 | .svelte-kit 157 | 158 | ### VisualStudioCode ### 159 | .vscode/* 160 | !.vscode/settings.json 161 | !.vscode/tasks.json 162 | !.vscode/launch.json 163 | !.vscode/extensions.json 164 | !.vscode/*.code-snippets 165 | 166 | # Local History for Visual Studio Code 167 | .history/ 168 | 169 | # Built Visual Studio Code Extensions 170 | *.vsix 171 | 172 | ### VisualStudioCode Patch ### 173 | # Ignore all local history of files 174 | .history 175 | .ionide 176 | .vercel 177 | 178 | dist/* 179 | !dist/readme.md 180 | !dist/index.cjs 181 | -------------------------------------------------------------------------------- /src/handler/rss.ts: -------------------------------------------------------------------------------- 1 | import type fxmManager from '../fxmManager'; 2 | import { useInternalRestAPI } from '../fxmManager'; 3 | import { router, handler } from 'handlers.js'; 4 | import { request, response } from 'handlers.js'; 5 | import { Feed } from 'feed'; 6 | 7 | import { sourceNamesInZH } from '../constant'; 8 | 9 | export class RSSHandler extends router { 10 | private fxmManager: fxmManager; 11 | 12 | constructor(fxmManager: fxmManager) { 13 | super(); 14 | this.fxmManager = fxmManager; 15 | this.mount(); 16 | } 17 | 18 | async requestPrice(from: string, to: string, excludeSource: string[] = []) { 19 | const sources = ( 20 | await useInternalRestAPI(`info`, this.fxmManager) 21 | ).sources.filter((source) => !excludeSource.includes(source)); 22 | 23 | const answer = []; 24 | 25 | await Promise.all( 26 | sources.map(async (source) => { 27 | try { 28 | const buyPrices = await useInternalRestAPI( 29 | `${source}/${to}/${from}/?precision=4&fees=0&amount=100`, 30 | this.fxmManager, 31 | ); 32 | 33 | const sellPrices = await useInternalRestAPI( 34 | `${source}/${from}/${to}/?precision=4&fees=0&amount=100&reverse`, 35 | this.fxmManager, 36 | ); 37 | 38 | answer.push({ 39 | sell: sellPrices, 40 | buy: buyPrices, 41 | source, 42 | }); 43 | } catch (e) { 44 | console.error( 45 | `not suppported: ${source} with ${from} to ${to}`, 46 | e, 47 | ); 48 | } 49 | return ''; 50 | }), 51 | ); 52 | 53 | return answer; 54 | } 55 | 56 | mount() { 57 | const toRSS = async ( 58 | request: request, 59 | response: response, 60 | ) => { 61 | if (request.params.from) 62 | request.params.from = request.params.from.toUpperCase(); 63 | 64 | if (request.params.to) 65 | request.params.to = request.params.to.toUpperCase(); 66 | 67 | const { from, to } = request.params; 68 | 69 | const feed = new Feed({ 70 | title: `FXRate 实时 ${from} <=> ${to} 汇率信息`, 71 | updated: new Date(), 72 | id: 'https://github.com/186526/fxrate', 73 | copyright: 74 | 'MIT, Data copyright belongs to its source. More details at .', 75 | author: { 76 | name: 'Bo Xu', 77 | email: 'i@186526.xyz', 78 | link: 'https://186526.xyz', 79 | }, 80 | }); 81 | 82 | const prices = await this.requestPrice(from, to); 83 | 84 | prices.forEach((price) => { 85 | const description = `现汇买入: ${price.buy.remit} 现钞买入: ${price.buy.cash} 买入中间价: ${price.buy.middle} 买入更新时间: ${price.buy.updated}\n现汇卖出: ${price.sell.remit} 现钞卖出: ${price.sell.cash} 卖出中间价: ${price.sell.middle} 卖出更新时间: ${price.sell.updated}`; 86 | 87 | feed.addItem({ 88 | title: `${sourceNamesInZH[price.source] ?? price.source}`, 89 | link: `https://github.com/186526/fxrate`, 90 | description: description, 91 | content: description, 92 | date: new Date(price.buy.updated ?? price.sell.updated), 93 | }); 94 | }); 95 | 96 | response.body = feed.atom1(); 97 | response.headers.set('Content-Type', 'application/xml'); 98 | response.status = 200; 99 | 100 | return response; 101 | }; 102 | this.binding('/:from/:to', new handler('GET', [toRSS])); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/FXGetter/unionpay.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FXRate, currency } from 'src/types'; 3 | 4 | import { create, all } from 'mathjs'; 5 | 6 | const math = create(all, { 7 | number: 'Fraction', 8 | }); 9 | 10 | const getUnionPayFXRates = async (): Promise => { 11 | let currentDate = parseInt( 12 | new Date().toISOString().split('T')[0].replaceAll('-', ''), 13 | ); 14 | 15 | let res = await axios 16 | .get(`https://www.unionpayintl.com/upload/jfimg/${currentDate}.json`, { 17 | headers: { 18 | 'User-Agent': 19 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 20 | }, 21 | }) 22 | .catch(() => { 23 | return { status: 404 }; 24 | }); 25 | 26 | while (res.status !== 200) { 27 | currentDate -= 1; 28 | 29 | console.log( 30 | currentDate + 1, 31 | 'UnionPay FXRate not found, trying', 32 | currentDate, 33 | ); 34 | 35 | res = await axios.get( 36 | `https://www.unionpayintl.com/upload/jfimg/${currentDate}.json`, 37 | { 38 | headers: { 39 | 'User-Agent': 40 | process.env['HEADER_USER_AGENT'] ?? 41 | 'fxrate axios/latest', 42 | }, 43 | }, 44 | ); 45 | } 46 | 47 | const data: { 48 | exchangeRateJson: { 49 | transCur: currency; 50 | baseCur: currency; 51 | rateData: number; 52 | }[]; 53 | curDate: string; 54 | } = (res as any).data; 55 | 56 | const date = new Date(`${data.curDate} 16:30 UTC+8`); 57 | 58 | const answerMap: { 59 | [from: string]: { 60 | [to: string]: { 61 | forward: number; 62 | reverse: number; 63 | }; 64 | }; 65 | } = {}; 66 | 67 | data.exchangeRateJson.forEach((rate) => { 68 | let firstCurr = rate.transCur, 69 | secondCurr = rate.baseCur, 70 | isReverse = false; 71 | 72 | if (!answerMap[rate.transCur]) { 73 | if (answerMap[rate.baseCur]) { 74 | firstCurr = rate.baseCur; 75 | secondCurr = rate.transCur; 76 | isReverse = true; 77 | } 78 | } 79 | 80 | if (!answerMap[firstCurr]) { 81 | answerMap[firstCurr] = {}; 82 | } 83 | 84 | if (!answerMap[firstCurr][secondCurr]) { 85 | answerMap[firstCurr][secondCurr] = { 86 | forward: undefined, 87 | reverse: undefined, 88 | }; 89 | } 90 | 91 | if (isReverse) { 92 | answerMap[firstCurr][secondCurr].reverse = math.divide( 93 | 1, 94 | rate.rateData, 95 | ); 96 | } else { 97 | answerMap[firstCurr][secondCurr].forward = rate.rateData; 98 | } 99 | }); 100 | 101 | const answer: FXRate[] = []; 102 | 103 | Object.keys(answerMap).forEach((from) => { 104 | Object.keys(answerMap[from]).forEach((to) => { 105 | const k: FXRate = { 106 | currency: { 107 | from: from as currency, 108 | to: to as currency, 109 | }, 110 | rate: {}, 111 | updated: date, 112 | unit: 1, 113 | }; 114 | if (answerMap[from][to].forward) { 115 | k.rate.sell = { 116 | remit: answerMap[from][to].forward, 117 | cash: answerMap[from][to].forward, 118 | }; 119 | } 120 | if (answerMap[from][to].reverse) { 121 | k.rate.buy = { 122 | remit: answerMap[from][to].reverse, 123 | cash: answerMap[from][to].reverse, 124 | }; 125 | } 126 | answer.push(k); 127 | }); 128 | }); 129 | 130 | return answer.sort(); 131 | }; 132 | 133 | export default getUnionPayFXRates; 134 | -------------------------------------------------------------------------------- /src/FXGetter/spdb.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import cheerio from 'cheerio'; 4 | 5 | import { currency, FXRate } from 'src/types'; 6 | 7 | import crypto from 'crypto'; 8 | import https from 'https'; 9 | import { SPDBFXReqInfo } from './spdb.d'; 10 | 11 | /** 12 | * Handle this problem with Node 18 13 | * write EPROTO B8150000:error:0A000152:SSL routines:final_renegotiate:unsafe legacy renegotiation disabled 14 | * **/ 15 | const allowLegacyRenegotiationforNodeJsOptions = { 16 | httpsAgent: new https.Agent({ 17 | // allow sb SPDB to use legacy renegotiation 18 | // 💩 SPDB 19 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 20 | }), 21 | }; 22 | 23 | const getSPDBFXRates = async (): Promise => { 24 | const req = await axios.post( 25 | 'https://www.spdb.com.cn/api/search', 26 | { 27 | metadata: 'NAME|ASK|BID|CODE|CREATE_DATE', 28 | size: 100, 29 | chlid: 1061, 30 | searchword: '', 31 | }, 32 | { 33 | ...allowLegacyRenegotiationforNodeJsOptions, 34 | headers: { 35 | 'User-Agent': 36 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 37 | }, 38 | }, 39 | ); 40 | 41 | const data: SPDBFXReqInfo[] = req.data.data.content; 42 | 43 | return data 44 | .map((d) => { 45 | if (!d.CurrencyName) return null; // means that currency not visible globally (secret for SPDB) 46 | 47 | const fromCurrency = d.CurrencyName.split(' ')[1] as currency; 48 | 49 | return { 50 | currency: { 51 | from: fromCurrency, 52 | to: 'CNY' as currency.CNY, 53 | }, 54 | 55 | rate: { 56 | buy: { 57 | cash: parseFloat(d.CashBuyPrc), 58 | remit: parseFloat(d.BuyPrc), 59 | }, 60 | sell: { 61 | cash: parseFloat(d.CashSellPrc), 62 | remit: parseFloat(d.SellPrc), 63 | }, 64 | middle: parseFloat(d.MdlPrc), 65 | }, 66 | 67 | updated: new Date(d['CREATE_DATE'] + ' UTC+8'), 68 | unit: parseInt(d.ExgRtUnt), 69 | } as FXRate; 70 | }) 71 | .sort(); 72 | }; 73 | 74 | const getSPDBFXRatesByOldHTML = async (): Promise => { 75 | const req = await axios.get('https://www.spdb.com.cn/wh_pj/index.shtml', { 76 | ...allowLegacyRenegotiationforNodeJsOptions, 77 | headers: { 78 | 'User-Agent': 79 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 80 | }, 81 | }); 82 | 83 | console.log(req.data); 84 | 85 | const $ = cheerio.load(req.data); 86 | 87 | const updatedTime = new Date($('.fine_title > p').text() + ' UTC+8'); 88 | 89 | return $('.table04 > tbody > tr') 90 | .toArray() 91 | .map((el) => { 92 | const toCurrency = $($(el).children()[0]) 93 | .text() 94 | .split(' ')[1] 95 | .replace('\n', '') as currency; 96 | 97 | const result: FXRate = { 98 | currency: { 99 | from: toCurrency, 100 | to: 'CNY' as currency.CNY, 101 | }, 102 | 103 | rate: { 104 | buy: { 105 | cash: parseFloat($($(el).children()[3]).text()), 106 | remit: parseFloat($($(el).children()[2]).text()), 107 | }, 108 | sell: { 109 | cash: parseFloat($($(el).children()[4]).text()), 110 | remit: parseFloat($($(el).children()[4]).text()), 111 | }, 112 | middle: parseFloat($($(el).children()[1]).text()), 113 | }, 114 | 115 | unit: toCurrency == 'JPY' ? 100000 : 100, 116 | updated: updatedTime, 117 | }; 118 | return result; 119 | }) 120 | .sort(); 121 | }; 122 | 123 | export default getSPDBFXRates; 124 | 125 | export { getSPDBFXRatesByOldHTML }; 126 | -------------------------------------------------------------------------------- /src/FXGetter/ccb.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from 'fast-xml-parser'; 2 | import { FXRate, currency } from 'src/types'; 3 | import axios from 'axios'; 4 | 5 | import crypto from 'crypto'; 6 | import https from 'https'; 7 | 8 | /** 9 | * Handle this problem with Node 18 10 | * write EPROTO B8150000:error:0A000152:SSL routines:final_renegotiate:unsafe legacy renegotiation disabled 11 | * **/ 12 | const allowLegacyRenegotiationforNodeJsOptions = { 13 | httpsAgent: new https.Agent({ 14 | // allow sb CCB to use legacy renegotiation 15 | // 💩 CCB 16 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 17 | }), 18 | }; 19 | 20 | const parser = new XMLParser(); 21 | 22 | const currencyMap = { 23 | '840': { name: 'USD' as currency.USD }, 24 | '978': { name: 'EUR' as currency.EUR }, 25 | '826': { name: 'GBP' as currency.GBP }, 26 | '392': { name: 'JPY' as currency.JPY }, 27 | '344': { name: 'HKD' as currency.HKD }, 28 | '36': { name: 'AUD' as currency.AUD }, 29 | '124': { name: 'CAD' as currency.CAD }, 30 | '756': { name: 'CHF' as currency.CHF }, 31 | '702': { name: 'SGD' as currency.SGD }, 32 | '208': { name: 'DKK' as currency.DKK }, 33 | '578': { name: 'NOK' as currency.NOK }, 34 | '752': { name: 'SEK' as currency.SEK }, 35 | '410': { name: 'KRW' as currency.KRW }, 36 | '554': { name: 'NZD' as currency.NZD }, 37 | '446': { name: 'MOP' as currency.MOP }, 38 | '710': { name: 'ZAR' as currency.ZAR }, 39 | '764': { name: 'THB' as currency.THB }, 40 | '458': { name: 'MYR' as currency.MYR }, 41 | '643': { name: 'RUB' as currency.RUB }, 42 | '398': { name: 'KZT' as currency.KZT }, 43 | '784': { name: 'AED' as currency.AED }, 44 | '682': { name: 'SAR' as currency.SAR }, 45 | '348': { name: 'HUF' as currency.HUF }, 46 | '484': { name: 'MXN' as currency.MXN }, 47 | '985': { name: 'PLN' as currency.PLN }, 48 | '949': { name: 'TRY' as currency.TRY }, 49 | '203': { name: 'CZK' as currency.CZK }, 50 | '376': { name: 'ILS' as currency.ILS }, 51 | '496': { name: 'MNT' as currency.MNT }, 52 | }; 53 | 54 | const getCCBFXRates = async (): Promise => { 55 | const req = await axios.get( 56 | 'https://www.ccb.com/cn/home/news/jshckpj_new.xml', 57 | { 58 | ...allowLegacyRenegotiationforNodeJsOptions, 59 | headers: { 60 | 'User-Agent': 61 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 62 | }, 63 | }, 64 | ); 65 | const settlements = parser.parse(req.data)['ReferencePriceSettlements'][ 66 | 'ReferencePriceSettlement' 67 | ]; 68 | 69 | const result = settlements.map((data: any) => { 70 | if (!(data['Ofrd_Ccy_CcyCd'] in currencyMap)) { 71 | console.log( 72 | `[${new Date().toUTCString()}] [CCB] Unsupported currency code ${data['Ofrd_Ccy_CcyCd']}, skipped.`, 73 | ); 74 | return null; 75 | } 76 | 77 | return { 78 | currency: { 79 | from: currencyMap[data['Ofrd_Ccy_CcyCd']].name, 80 | to: 'CNY' as currency.CNY, 81 | }, 82 | rate: { 83 | buy: { 84 | cash: data['BidRateOfCash'], 85 | remit: data['BidRateOfCcy'], 86 | }, 87 | sell: { 88 | cash: data['OfrRateOfCash'], 89 | remit: data['OfrRateOfCcy'], 90 | }, 91 | middle: data['Mdl_ExRt_Prc'], 92 | }, 93 | unit: 1, 94 | updated: new Date( 95 | ((date: number, time: number) => { 96 | const dateStringArray = date.toString().split(''); 97 | const timeStringArray = time 98 | .toString() 99 | .padStart(6, '0') 100 | .split(''); 101 | dateStringArray.splice(4, 0, '-'); 102 | dateStringArray.splice(7, 0, '-'); 103 | timeStringArray.splice(2, 0, ':'); 104 | timeStringArray.splice(5, 0, ':'); 105 | return `${dateStringArray.join('')} ${timeStringArray.join('')} UTC+8`; 106 | })(data['LstPr_Dt'], data['LstPr_Tm']), 107 | ), 108 | } as FXRate; 109 | }); 110 | return result.sort(); 111 | }; 112 | 113 | export default getCCBFXRates; 114 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # FXRate 2 | 3 | Yet another foreign exchange rate API project. 4 | 5 | --- 6 | 7 | ## Usage 8 | 9 | Test URL: 10 | 11 | Web UI: [186526/fxrate-web](https://github.com/186526/fxrate-web) (Still work in progress) 12 | 13 | ### Rest API v1 Usage 14 | 15 | - `GET (/v1)/info` - show instance's details. 16 | 17 | ```typescript 18 | type source = string; 19 | 20 | interface result { 21 | status: 'ok' as string; 22 | sources: source[]; 23 | version: string; 24 | apiVersion: 'v1'; 25 | environment: 'production' | 'development'; 26 | } 27 | 28 | export default result; 29 | ``` 30 | 31 | - `GET (/v1)/:source/` - show source's details. 32 | 33 | ```typescript 34 | enum currency { 35 | // For example 36 | USD = 'USD'; 37 | } 38 | 39 | type UTCString = string; 40 | 41 | interface result { 42 | status: 'ok' as string; 43 | source: source; 44 | currency: currency[]; 45 | date: UTCString; 46 | } 47 | 48 | export default result; 49 | ``` 50 | 51 | - `GET (/v1)/:source/:from(?reverse&precision&amount&fees)` - show currency's FX rates to other currency in source's db. 52 | 53 | ```typescript 54 | // query use ?reverse means calculating how much currency is needed to obtain the $amount $from currency is needed. 55 | // query use ?precision means get data rounded to $precision decimal place. use -1 as the flag means that getting infinite recurrent decimal. 56 | // query use ?amount means convert from/to $amount currency. 57 | // query use ?fees means add $fees% ftf. 58 | interface FXRate { 59 | updated: UTCString; 60 | // number: 721.55 61 | // string: 721.(55) 62 | cash: number | string | false; 63 | remit: number | string | false; 64 | middle: number | string; 65 | } 66 | 67 | interface result { 68 | [to in keyof curreny]: FXRate; 69 | } 70 | 71 | return result; 72 | ``` 73 | 74 | - `GET (/v1)/:source/:from/:to(?reverse&precision&amount&fees)` - show currency's FX rates to other currency in source's db. 75 | 76 | ```typescript 77 | type result = FXRate; 78 | 79 | export default result; 80 | ``` 81 | 82 | - `GET (/v1)/:source/:from/:to/:type(/:amount)(?reverse&precision&amount&fees)` - show currency's FX rates to other currency in source's db. 83 | 84 | ```typescript 85 | type result = FXRate; 86 | 87 | export default result[type]; 88 | ``` 89 | 90 | ### JSONRPC v2 API Usage 91 | 92 | Endpoint `(/v1)/jsonrpc/v2` 93 | 94 | - `instanceInfo` 95 | 96 | Params: `undefined` 97 | Response: Follow `GET (/v1)/info` 98 | 99 | - `listCurrencies` 100 | 101 | Params: 102 | 103 | ```typescript 104 | { 105 | source: string; 106 | } 107 | ``` 108 | 109 | Response: Follow `GET (/v1)/:source/` 110 | 111 | - `listFXRates` 112 | 113 | Params: 114 | 115 | ```typescript 116 | { 117 | source: string; 118 | from: currency; 119 | precision: number = 2; 120 | amount: number = 100; 121 | fees: number = 0; 122 | reverse: boolean = false; 123 | } 124 | ``` 125 | 126 | Response: Follow `GET (/v1)/:source/:from(?reverse&precision&amount&fees)` 127 | 128 | - `getFXRates` 129 | 130 | Params: 131 | 132 | ```typescript 133 | { 134 | source: string; 135 | from: currency; 136 | to: currency; 137 | type: 'remit' | 'cash' | 'middle' | 'all'; 138 | precision: number = 2; 139 | amount: number = 100; 140 | fees: number = 0; 141 | reverse: boolean = false; 142 | } 143 | ``` 144 | 145 | Response: Follow `GET (/v1)/:source/:from/:to/:type(/:amount)(?reverse&precision&amount&fees)` 146 | 147 | ## Running 148 | 149 | Some APIs require configuration tokens to work properly. 150 | 151 | | environment variables | value | details | defaults | 152 | | --------------------- | ----------------- | ----------------------------------------------------------- | --------------------- | 153 | | `ENABLE_WISE` | `1 \| 0` | Enable Wise FX Rates API | `0` | 154 | | `WISE_TOKEN` | `string` | configure Wise's API Token | `null` | 155 | | `WISE_SANDBOX_API` | `1 \| 0` | Using Wise's sandbox API environment. | `0` | 156 | | `ENABLE_CORS` | `domain` | configure FXRate's API to allow CORS | `null` | 157 | | `HEADER_USER_AGENT` | `userAgentString` | configure spider to use which user agent to fetch from site | `fxrate axios/latest` | 158 | 159 | ```bash 160 | yarn install 161 | yarn dev 162 | 163 | ## In production 164 | 165 | yarn start 166 | ``` 167 | 168 | ## License 169 | 170 | ```markdown 171 | The program's code is under MIT LICENSE (SEE LICENSE IN LICENSE.MIT). 172 | 173 | Data copyright belongs to its source (SEE LICENSE IN LICENSE.DATA). 174 | ``` 175 | -------------------------------------------------------------------------------- /src/FXGetter/bochk.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import cheerio from 'cheerio'; 3 | 4 | import { currency, FXRate } from 'src/types.d'; 5 | 6 | const currencyMapping = { 7 | '人民幣(在岸)': currency.CNY, 8 | 人民幣: currency.CNY, 9 | '人民幣(離岸)': currency.CNH, 10 | 美元: currency.USD, 11 | 英鎊: currency.GBP, 12 | 日圓: currency.JPY, 13 | 澳元: currency.AUD, 14 | 紐元: currency.NZD, 15 | 加元: currency.CAD, 16 | 歐羅: currency.EUR, 17 | 瑞士法郎: currency.CHF, 18 | 丹麥克郎: currency.DKK, 19 | 挪威克郎: currency.NOK, 20 | 瑞典克郎: currency.SEK, 21 | 新加坡元: currency.SGD, 22 | 泰國銖: currency.THB, 23 | 文萊元: currency.BND, 24 | 南非蘭特: currency.ZAR, 25 | 印尼盾: currency.IDR, 26 | 紐西蘭元: currency.NZD, 27 | 加拿大元: currency.CAD, 28 | 印度盧比: currency.INR, 29 | 韓國圜: currency.KRW, 30 | 澳門元: currency.MOP, 31 | 菲律賓彼索: currency.PHP, 32 | 俄羅斯盧布: currency.RUB, 33 | 新台幣: currency.TWD, 34 | }; 35 | 36 | const getBOCHKFxRatesBasis = async ( 37 | link: string, 38 | ): Promise<{ 39 | [currency: string]: { 40 | buy: number; 41 | sell: number; 42 | updatedDate: Date; 43 | }; 44 | }> => { 45 | const answer = {}; 46 | 47 | const res = await axios.get(link, { 48 | headers: { 49 | 'User-Agent': 50 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 51 | }, 52 | }); 53 | 54 | const $ = cheerio.load(res.data); 55 | const updatedDate = new Date( 56 | $($('div.form_area table tbody tr').toArray().at(-2)) 57 | .text() 58 | .trim() 59 | .split(':')[1] 60 | .replaceAll('\n', '') 61 | .replaceAll('\t', '') + ' UTC+8', 62 | ); 63 | 64 | Array.from( 65 | new Set( 66 | $('div.form_area table tbody tr') 67 | .slice(2) 68 | .slice(0, -2) 69 | .toArray() 70 | .map((el) => { 71 | const e = $(el); 72 | const zhName = e.find('td:nth-child(1)').text().trim(); 73 | 74 | if (!currencyMapping[zhName]) { 75 | console.error('Unknown currency:', zhName); 76 | } 77 | 78 | const enName = currencyMapping[zhName] || 'unknown'; 79 | 80 | const Buy = e.find('td:nth-child(2)').text().trim(); 81 | const Sell = e.find('td:nth-child(3)').text().trim(); 82 | 83 | answer[enName] = { 84 | buy: parseFloat(Buy), 85 | sell: parseFloat(Sell), 86 | updatedDate, 87 | }; 88 | }), 89 | ), 90 | ); 91 | 92 | return answer; 93 | }; 94 | 95 | export const getBOCHKFxRatesRemit = () => 96 | getBOCHKFxRatesBasis( 97 | `https://www.bochk.com/whk/rates/exchangeRatesHKD/exchangeRatesHKD-input.action?lang=hk`, 98 | ); 99 | export const getBOCHKFxRatesCash = () => 100 | getBOCHKFxRatesBasis( 101 | `https://www.bochk.com/whk/rates/exchangeRatesForCurrency/exchangeRatesForCurrency-input.action?lang=hk`, 102 | ); 103 | 104 | export const getBOCHKFxRates = async (): Promise => { 105 | const result = await Promise.all([ 106 | getBOCHKFxRatesCash(), 107 | getBOCHKFxRatesRemit(), 108 | ]); 109 | 110 | const currencyList = Array.from( 111 | new Set( 112 | result 113 | .map((k) => { 114 | return Object.keys(k); 115 | }) 116 | .flat(), 117 | ), 118 | ); 119 | 120 | return currencyList 121 | .map((k): FXRate => { 122 | const cash = result[0][k]; 123 | const remit = result[1][k]; 124 | 125 | let updatedTime = new Date(); 126 | 127 | if (cash) updatedTime = cash.updatedDate; 128 | if (remit) updatedTime = remit.updatedDate; 129 | if (cash && remit) 130 | updatedTime = 131 | cash.updatedDate > remit.updatedDate 132 | ? cash.updatedDate 133 | : remit.updatedDate; 134 | 135 | const answer: FXRate = { 136 | currency: { 137 | from: k as unknown as currency.unknown, 138 | to: 'HKD' as currency.HKD, 139 | }, 140 | updated: updatedTime, 141 | rate: { 142 | buy: {}, 143 | sell: {}, 144 | }, 145 | unit: 1, 146 | }; 147 | 148 | if (cash) { 149 | answer.rate.buy.cash = cash.buy; 150 | answer.rate.sell.cash = cash.sell; 151 | } 152 | 153 | if (remit) { 154 | answer.rate.buy.remit = remit.buy; 155 | answer.rate.sell.remit = remit.sell; 156 | } 157 | 158 | return answer; 159 | }) 160 | .sort(); 161 | }; 162 | 163 | export default getBOCHKFxRates; 164 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import http from 'node:http'; 3 | 4 | import esMain from 'es-main'; 5 | 6 | import rootRouter, { handler } from 'handlers.js'; 7 | 8 | import fxmManager from './fxmManager'; 9 | import { useBasic } from './fxmManager'; 10 | 11 | import getBOCFXRatesFromBOC from './FXGetter/boc'; 12 | import getBOCHKFxRates from './FXGetter/bochk'; 13 | import getICBCFXRates from './FXGetter/icbc'; 14 | import getCIBFXRates, { getCIBHuanyuFXRates } from './FXGetter/cib'; 15 | import getCCBFXRates from './FXGetter/ccb'; 16 | import getABCFXRates from './FXGetter/abc'; 17 | import getBOCOMFXRates from './FXGetter/bocom'; 18 | import getPSBCFXRates from './FXGetter/psbc'; 19 | import getCMBFXRates from './FXGetter/cmb'; 20 | import getPBOCFXRates from './FXGetter/pboc'; 21 | import getUnionPayFXRates from './FXGetter/unionpay'; 22 | import getJCBFXRates from './FXGetter/jcb'; 23 | import getWiseFXRates from './FXGetter/wise'; 24 | import getHSBCHKFXRates from './FXGetter/hsbc.hk'; 25 | import getHSBCCNFXRates from './FXGetter/hsbc.cn'; 26 | import getHSBCAUFXRates from './FXGetter/hsbc.au'; 27 | import getCITICCNFXRates from './FXGetter/citic.cn'; 28 | import getSPDBFXRates from './FXGetter/spdb'; 29 | import getNCBCNFXRates from './FXGetter/ncb.cn'; 30 | import getNCBHKFXRates from './FXGetter/ncb.hk'; 31 | import getXIBFXRates from './FXGetter/xib'; 32 | import getPABFXRates from './FXGetter/pab'; 33 | import getCEBFXRates from './FXGetter/ceb'; 34 | 35 | import mastercardFXM from './FXGetter/mastercard'; 36 | import visaFXM from './FXGetter/visa'; 37 | import { RSSHandler } from './handler/rss'; 38 | 39 | const Manager = new fxmManager({ 40 | boc: getBOCFXRatesFromBOC, 41 | bochk: getBOCHKFxRates, 42 | icbc: getICBCFXRates, 43 | cib: getCIBFXRates, 44 | cibHuanyu: getCIBHuanyuFXRates, 45 | ccb: getCCBFXRates, 46 | abc: getABCFXRates, 47 | bocom: getBOCOMFXRates, 48 | psbc: getPSBCFXRates, 49 | cmb: getCMBFXRates, 50 | pboc: getPBOCFXRates, 51 | unionpay: getUnionPayFXRates, 52 | jcb: getJCBFXRates, 53 | 'hsbc.hk': getHSBCHKFXRates, 54 | 'hsbc.cn': getHSBCCNFXRates, 55 | 'hsbc.au': getHSBCAUFXRates, 56 | 'citic.cn': getCITICCNFXRates, 57 | 'ncb.cn': getNCBCNFXRates, 58 | 'ncb.hk': getNCBHKFXRates, 59 | spdb: getSPDBFXRates, 60 | xib: getXIBFXRates, 61 | pab: getPABFXRates, 62 | ceb: getCEBFXRates, 63 | }); 64 | 65 | Manager.registerFXM('mastercard', new mastercardFXM()); 66 | Manager.registerFXM('visa', new visaFXM()); 67 | 68 | if (process.env.ENABLE_WISE != '0') { 69 | if (process.env.WISE_TOKEN == undefined) { 70 | console.error('WISE_TOKEN is not set. Use Wise Token from web.'); 71 | process.env.WISE_USE_TOKEN_FROM_WEB = '1'; 72 | } 73 | Manager.registerGetter( 74 | 'wise', 75 | getWiseFXRates( 76 | process.env.WISE_SANDBOX_API == '1', 77 | process.env.WISE_USE_TOKEN_FROM_WEB != '0', 78 | process.env.WISE_TOKEN, 79 | ), 80 | ); 81 | } 82 | 83 | export const makeInstance = async (App: rootRouter, Manager: fxmManager) => { 84 | App.binding( 85 | '/(.*)', 86 | new handler('ANY', [ 87 | async (_request, response) => { 88 | useBasic(response); 89 | response.status = 404; 90 | }, 91 | ]), 92 | ); 93 | 94 | App.useMappingAdapter(); 95 | 96 | App.binding( 97 | '/', 98 | App.create('ANY', async () => '200 OK\n\n/info - Instance Info\n'), 99 | ); 100 | 101 | App.binding( 102 | '/(.*)', 103 | new handler('ANY', [ 104 | async (request, response) => { 105 | Manager.log( 106 | `${request.ip} ${request.method} ${request.originURL}`, 107 | ); 108 | 109 | response.headers.set('X-Powered-By', `fxrate/latest`); 110 | response.headers.set( 111 | 'X-License', 112 | 'MIT, Data copyright belongs to its source. More details at .', 113 | ); 114 | }, 115 | ]), 116 | ); 117 | 118 | App.use([Manager], '/(.*)'); 119 | App.use([Manager], '/v1/(.*)'); 120 | 121 | const rssFeeder = new RSSHandler(Manager); 122 | App.use([rssFeeder], '/rss/(.*)'); 123 | 124 | return App; 125 | }; 126 | 127 | if ( 128 | process.env.VERCEL == '1' || 129 | ((_) => globalThis.esBuilt ?? esMain(_))(import.meta) 130 | ) { 131 | (async () => { 132 | globalThis.App = await makeInstance(new rootRouter(), Manager); 133 | 134 | if (process.env.VERCEL != '1') 135 | globalThis.App.listen(Number(process?.env?.PORT) || 8080); 136 | 137 | console.log( 138 | `[${new Date().toUTCString()}] Server is started at ${Number(process?.env?.PORT) || 8080} with NODE_ENV ${process.env.NODE_ENV || 'development'}.`, 139 | ); 140 | })(); 141 | } 142 | 143 | export default async (req: http.IncomingMessage, res: http.ServerResponse) => { 144 | const request = await globalThis.App.adapater.handleRequest(req); 145 | const response = await globalThis.App.adapater.router.respond(request); 146 | globalThis.App.adapater.handleResponse(response, res); 147 | }; 148 | 149 | export { Manager }; 150 | -------------------------------------------------------------------------------- /src/FXGetter/cib.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { FXRate, currency } from 'src/types'; 3 | import cheerio from 'cheerio'; 4 | 5 | import crypto from 'crypto'; 6 | import https from 'https'; 7 | 8 | import { 9 | round, 10 | fraction, 11 | divide, 12 | subtract, 13 | add, 14 | max, 15 | min, 16 | Fraction, 17 | } from 'mathjs'; 18 | 19 | /** 20 | * Handle this problem with Node 18 21 | * write EPROTO B8150000:error:0A000152:SSL routines:final_renegotiate:unsafe legacy renegotiation disabled 22 | **/ 23 | const allowLegacyRenegotiationforNodeJsOptions = { 24 | httpsAgent: new https.Agent({ 25 | // allow sb CIB to use legacy renegotiation 26 | // 💩 CIB 27 | secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, 28 | }), 29 | }; 30 | 31 | const getCIBFXRates = async (): Promise => { 32 | const resHTML = await axios.get( 33 | 'https://personalbank.cib.com.cn/pers/main/pubinfo/ifxQuotationQuery.do', 34 | { 35 | ...allowLegacyRenegotiationforNodeJsOptions, 36 | headers: { 37 | 'User-Agent': 38 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 39 | }, 40 | }, 41 | ); 42 | 43 | const res = await axios.get( 44 | `https://personalbank.cib.com.cn/pers/main/pubinfo/ifxQuotationQuery/list?_search=false&dataSet.nd=${Date.now()}&dataSet.rows=80&dataSet.page=1&dataSet.sidx=&dataSet.sord=asc`, 45 | { 46 | ...allowLegacyRenegotiationforNodeJsOptions, 47 | headers: { 48 | 'User-Agent': 49 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 50 | Cookie: resHTML.headers['set-cookie'] 51 | .map((cookie) => cookie.split(';')[0]) 52 | .join('; '), 53 | }, 54 | }, 55 | ); 56 | 57 | const FXRates: FXRate[] = []; 58 | 59 | if (res.status != 200 || resHTML.status != 200) 60 | throw new Error(`Get CIB FX Rates failed.`); 61 | 62 | const $ = cheerio.load(resHTML.data); 63 | 64 | const updateTime = new Date( 65 | $($('.labe_text')[0]) 66 | .text() 67 | .replaceAll('\n\t', '') 68 | .replaceAll(' ', '') 69 | .replaceAll('日期: ', '') 70 | .replaceAll('年', '-') 71 | .replaceAll('月', '-') 72 | .replaceAll('日', '') 73 | .split(' ') 74 | .filter((_, i) => i != 1) 75 | .join(' ') + ' UTC+8', 76 | ); 77 | 78 | res.data.rows.forEach((row) => { 79 | row = row.cell; 80 | const FXRate = { 81 | currency: { 82 | from: row[1] as currency.unknown, 83 | to: 'CNY' as currency.CNY, 84 | }, 85 | unit: parseFloat(row[2]) as number, 86 | updated: updateTime, 87 | rate: { 88 | buy: { 89 | remit: parseFloat(row[3]) as number, 90 | cash: parseFloat(row[5]) as number, 91 | }, 92 | sell: { 93 | remit: parseFloat(row[4]) as number, 94 | cash: parseFloat(row[6]) as number, 95 | }, 96 | middle: undefined as number, 97 | }, 98 | }; 99 | FXRate.rate.middle = 100 | (FXRate.rate.buy.remit + 101 | FXRate.rate.sell.remit + 102 | FXRate.rate.buy.cash + 103 | FXRate.rate.sell.cash) / 104 | 4; 105 | FXRates.push(FXRate); 106 | }); 107 | 108 | return FXRates; 109 | }; 110 | 111 | function promotePrice(a: number, b: number) { 112 | const mid = divide(add(fraction(a), fraction(b)), 2); 113 | const diff = subtract(fraction(a), mid); 114 | return round(subtract(a, divide(diff, 2)) as Fraction, 2); 115 | } 116 | 117 | const getCIBHuanyuFXRates = async (): Promise => { 118 | const origin = await getCIBFXRates(); 119 | return origin 120 | .map((rate) => { 121 | const originRate = JSON.parse(JSON.stringify(rate.rate)); 122 | rate.rate.buy.remit = promotePrice( 123 | originRate.buy.remit as number, 124 | originRate.sell.remit as number, 125 | ); 126 | rate.rate.sell.remit = promotePrice( 127 | originRate.sell.remit as number, 128 | originRate.buy.remit as number, 129 | ); 130 | // free cash to remit conversion 131 | rate.rate.buy.cash = max( 132 | originRate.buy.cash as number, 133 | rate.rate.buy.remit, 134 | ) as number; 135 | // use huanyu rate to buy remit online 136 | rate.rate.sell.cash = min( 137 | originRate.sell.cash as number, 138 | rate.rate.sell.remit, 139 | ) as number; 140 | 141 | rate.rate.middle = divide( 142 | add( 143 | rate.rate.buy.remit, 144 | rate.rate.sell.remit, 145 | rate.rate.buy.cash, 146 | rate.rate.sell.cash, 147 | ), 148 | 4, 149 | ) as number; 150 | 151 | return rate; 152 | }) 153 | .sort(); 154 | }; 155 | 156 | export default getCIBFXRates; 157 | export { getCIBHuanyuFXRates }; 158 | -------------------------------------------------------------------------------- /src/FXGetter/boc.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { XMLParser } from 'fast-xml-parser'; 3 | import { FXRate, currency } from '../types'; 4 | import cheerio from 'cheerio'; 5 | 6 | const parser = new XMLParser(); 7 | 8 | const enNames = { 9 | 阿联酋迪拉姆: 'AED', 10 | 澳大利亚元: 'AUD', 11 | 巴西里亚尔: 'BRL', 12 | 加拿大元: 'CAD', 13 | 瑞士法郎: 'CHF', 14 | 丹麦克朗: 'DKK', 15 | 欧元: 'EUR', 16 | 英镑: 'GBP', 17 | 港币: 'HKD', 18 | 印尼卢比: 'IDR', 19 | 印度卢比: 'INR', 20 | 日元: 'JPY', 21 | 韩国元: 'KRW', 22 | 澳门元: 'MOP', 23 | 林吉特: 'MYR', 24 | 挪威克朗: 'NOK', 25 | 新西兰元: 'NZD', 26 | 菲律宾比索: 'PHP', 27 | 卢布: 'RUB', 28 | 沙特里亚尔: 'SAR', 29 | 瑞典克朗: 'SEK', 30 | 新加坡元: 'SGD', 31 | 泰国铢: 'THB', 32 | 土耳其里拉: 'TRY', 33 | 新台币: 'TWD', 34 | 美元: 'USD', 35 | 南非兰特: 'ZAR', 36 | }; 37 | 38 | const getBOCFXRatesFromRSSHub = async ( 39 | RSSHubEndpoint: string = 'https://rsshub.app/boc/whpj', 40 | ): Promise => { 41 | // Thanks to https://rsshub.app/ for providing the RSS feed of BOC's FX rates 42 | const res = await axios.get(RSSHubEndpoint, { 43 | headers: { 44 | 'User-Agent': 45 | process.env['HEADER_USER_AGENT'] ?? 'fxrate axios/latest', 46 | }, 47 | }); 48 | 49 | const originalData: string = res.data; 50 | const jsonData = parser.parse(originalData).rss.channel.item; 51 | 52 | const FXrates: FXRate[] = []; 53 | 54 | jsonData.forEach((item: any) => { 55 | const tmp = item.description.split('
'); 56 | const FXRate: FXRate = { 57 | currency: { 58 | from: item.guid.split(' ')[1] as currency.unknown, 59 | to: 'CNY' as currency.CNY, 60 | }, 61 | rate: { 62 | buy: {}, 63 | sell: {}, 64 | middle: parseFloat(tmp[4].split(':')[1]), 65 | }, 66 | unit: 100, 67 | updated: new Date(item.pubDate), 68 | }; 69 | 70 | if (tmp[0].split(':')[1]) 71 | FXRate.rate.buy.remit = parseFloat(tmp[0].split(':')[1]); 72 | if (tmp[1].split(':')[1]) 73 | FXRate.rate.buy.cash = parseFloat(tmp[1].split(':')[1]); 74 | if (tmp[2].split(':')[1]) 75 | FXRate.rate.sell.remit = parseFloat(tmp[2].split(':')[1]); 76 | if (tmp[3].split(':')[1]) 77 | FXRate.rate.sell.cash = parseFloat(tmp[3].split(':')[1]); 78 | 79 | FXrates.push(FXRate); 80 | }); 81 | 82 | return FXrates; 83 | }; 84 | 85 | const getBOCFXRatesFromBOC = async (): Promise => { 86 | const result = await Promise.all( 87 | [ 88 | 'index.html', 89 | 'index_1.html', 90 | 'index_2.html', 91 | 'index_3.html', 92 | 'index_4.html', 93 | 'index_5.html', 94 | 'index_6.html', 95 | 'index_7.html', 96 | 'index_8.html', 97 | 'index_9.html', 98 | ].map(async (index) => { 99 | const res = await axios.get( 100 | `https://www.boc.cn/sourcedb/whpj/${index}`, 101 | { 102 | headers: { 103 | 'User-Agent': 104 | process.env['HEADER_USER_AGENT'] ?? 105 | 'fxrate axios/latest', 106 | }, 107 | }, 108 | ); 109 | const $ = cheerio.load(res.data); 110 | // Thanks to RSSHub for the code to get BOC's FX Rate 111 | return Array.from( 112 | new Set( 113 | $('div.publish table tbody tr') 114 | .slice(2) 115 | .toArray() 116 | .map((el) => { 117 | const e = $(el); 118 | const zhName = e.find('td:nth-child(1)').text(); 119 | const enName = enNames[zhName] || ''; 120 | const date = e.find('td:nth-child(7)').text(); 121 | 122 | const xhmr = e.find('td:nth-child(2)').text(); 123 | 124 | const xcmr = e.find('td:nth-child(3)').text(); 125 | 126 | const xhmc = e.find('td:nth-child(4)').text(); 127 | 128 | const xcmc = e.find('td:nth-child(5)').text(); 129 | 130 | const FXRate: FXRate = { 131 | currency: { 132 | from: enName as currency.unknown, 133 | to: 'CNY' as currency.CNY, 134 | }, 135 | rate: { 136 | buy: {}, 137 | sell: {}, 138 | middle: parseFloat( 139 | e.find('td:nth-child(6)').text(), 140 | ), 141 | }, 142 | updated: new Date(date + ' UTC+8'), 143 | unit: 100, 144 | }; 145 | 146 | if (xhmr) FXRate.rate.buy.remit = parseFloat(xhmr); 147 | if (xcmr) FXRate.rate.buy.cash = parseFloat(xcmr); 148 | if (xhmc) FXRate.rate.sell.remit = parseFloat(xhmc); 149 | if (xcmc) FXRate.rate.sell.cash = parseFloat(xcmc); 150 | 151 | return FXRate; 152 | }), 153 | ), 154 | ).sort(); 155 | }), 156 | ); 157 | return result.flat().sort(); 158 | }; 159 | 160 | export default getBOCFXRatesFromBOC; 161 | export { getBOCFXRatesFromRSSHub }; 162 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | interface infoResponse { 2 | environment: string; 3 | sources: string[]; 4 | version: string; 5 | status: 'ok' | string; 6 | apiVersion: string; 7 | } 8 | 9 | interface fxRateResponse { 10 | cash?: number | string; 11 | middle: number | string; 12 | remit?: number | string; 13 | updated: Date; 14 | } 15 | 16 | interface fxRateListResponse { 17 | [currency: string]: fxRateResponse; 18 | } 19 | 20 | interface currencyListResponse { 21 | currency: string[]; 22 | date: Date; 23 | } 24 | 25 | type getFXRateResponse = number | string | fxRateResponse; 26 | 27 | class FXRates { 28 | public endpoint: URL; 29 | 30 | private requestDetails: { methods: string; params: any; id: string }[] = []; 31 | private callbacks: { [id: string]: (resp: any) => any } = {}; 32 | 33 | private inBatch = false; 34 | 35 | protected fetch = globalThis.fetch.bind(globalThis); 36 | 37 | private generateID() { 38 | function _p8(s?: boolean) { 39 | const p = (Math.random().toString(16) + '000000000').substr(2, 8); 40 | return s ? '-' + p.substr(0, 4) + '-' + p.substr(4, 4) : p; 41 | } 42 | return _p8() + _p8(true) + _p8(true) + _p8(); 43 | } 44 | constructor(endpoint: URL = new URL('http://localhost:8080/v1/jsonrpc')) { 45 | this.endpoint = endpoint; 46 | } 47 | 48 | private addToQueue( 49 | method: string, 50 | params: any, 51 | callback?: (resp: T) => any, 52 | ): this | Promise { 53 | const id = this.generateID(); 54 | 55 | this.requestDetails.push({ 56 | methods: method, 57 | params: params, 58 | id: id, 59 | }); 60 | 61 | this.callbacks[id] = callback; 62 | 63 | if (this.inBatch) return this; 64 | else { 65 | const answer = new Promise((resolve) => { 66 | this.callbacks[id] = resolve; 67 | }); 68 | 69 | this.done(); 70 | 71 | return answer; 72 | } 73 | } 74 | 75 | info(callback?: (resp: infoResponse) => any) { 76 | return this.addToQueue('instanceInfo', '', callback); 77 | } 78 | 79 | listCurrencies( 80 | source: string, 81 | callback?: (resp: currencyListResponse) => any, 82 | ) { 83 | return this.addToQueue( 84 | 'listCurrencies', 85 | { source }, 86 | ({ currency, date }) => { 87 | callback({ 88 | currency, 89 | date: new Date(date), 90 | }); 91 | }, 92 | ); 93 | } 94 | 95 | listFXRates( 96 | source: string, 97 | from: string, 98 | callback?: (resp: fxRateListResponse) => any, 99 | precision = 2, 100 | amount = 100, 101 | fees = 0, 102 | reverse = false, 103 | ) { 104 | return this.addToQueue( 105 | 'listFXRates', 106 | { source, from, precision, amount, fees, reverse }, 107 | (resp) => { 108 | const anz = {}; 109 | for (const x in resp) { 110 | anz[x] = {}; 111 | if (resp[x].cash) anz[x].cash = resp[x].cash; 112 | if (resp[x].remit) anz[x].remit = resp[x].remit; 113 | anz[x].middle = resp[x].middle; 114 | anz[x].updated = new Date(resp[x].updated); 115 | } 116 | callback(anz); 117 | }, 118 | ); 119 | } 120 | 121 | getFXRate( 122 | source: string, 123 | from: string, 124 | to: string, 125 | callback: (rates: getFXRateResponse) => any, 126 | type: 'cash' | 'remit' | 'middle' | 'all' = 'all', 127 | precision = 2, 128 | amount = 100, 129 | fees = 0, 130 | reverse = false, 131 | ) { 132 | return this.addToQueue( 133 | 'getFXRate', 134 | { 135 | source, 136 | from, 137 | to, 138 | type, 139 | precision, 140 | amount, 141 | fees, 142 | reverse, 143 | }, 144 | (resp) => { 145 | if (typeof resp == 'object') { 146 | resp.updated = new Date(resp.updated); 147 | callback(resp); 148 | } else callback(resp); 149 | }, 150 | ); 151 | } 152 | 153 | batch() { 154 | this.inBatch = true; 155 | return this; 156 | } 157 | 158 | async done() { 159 | this.inBatch = false; 160 | 161 | const requestDetails = this.requestDetails, 162 | callbacks = this.callbacks; 163 | 164 | this.requestDetails = []; 165 | this.callbacks = {}; 166 | 167 | const responseBody = requestDetails.map( 168 | (k) => 169 | new Object({ 170 | jsonrpc: '2.0', 171 | id: k.id, 172 | method: k.methods, 173 | params: k.params, 174 | }), 175 | ); 176 | 177 | const resp = await this.fetch(this.endpoint, { 178 | method: 'POST', 179 | body: JSON.stringify(responseBody), 180 | }); 181 | 182 | let body: any; 183 | 184 | const content = await resp.text(); 185 | 186 | try { 187 | body = JSON.parse(content); 188 | } catch (e) { 189 | console.error(e); 190 | console.error(content); 191 | console.error(responseBody); 192 | throw new Error('Error parsing response'); 193 | } 194 | 195 | const handler = (k) => { 196 | if (k.error) { 197 | throw new Error(k.error.message + '\n' + k.error.data); 198 | } 199 | 200 | callbacks[k.id](k.result); 201 | }; 202 | 203 | if (body instanceof Array) { 204 | body.forEach((k) => { 205 | try { 206 | handler(k); 207 | } catch (e) { 208 | console.error('Error in batch request:', e); 209 | } 210 | }); 211 | } else handler(body); 212 | 213 | return; 214 | } 215 | } 216 | 217 | export default FXRates; 218 | -------------------------------------------------------------------------------- /src/FXGetter/mastercard.ts: -------------------------------------------------------------------------------- 1 | import fxManager from '../fxm/fxManager'; 2 | import syncRequest from 'sync-request'; 3 | import axios from 'axios'; 4 | import { fraction, divide } from 'mathjs'; 5 | 6 | import { LRUCache } from 'lru-cache'; 7 | import { currency } from 'src/types'; 8 | 9 | const cache = new LRUCache({ 10 | max: 500, 11 | ttl: 1000 * 60 * 30, 12 | ttlAutopurge: true, 13 | }); 14 | 15 | const currenciesList: string[] = [ 16 | 'AFN', 17 | 'ALL', 18 | 'DZD', 19 | 'AOA', 20 | 'ARS', 21 | 'AMD', 22 | 'AWG', 23 | 'AUD', 24 | 'AZN', 25 | 'BSD', 26 | 'BHD', 27 | 'BDT', 28 | 'BBD', 29 | 'BYN', 30 | 'BZD', 31 | 'BMD', 32 | 'BTN', 33 | 'BOB', 34 | 'BAM', 35 | 'BWP', 36 | 'BRL', 37 | 'BND', 38 | 'BGN', 39 | 'BIF', 40 | 'KHR', 41 | 'CAD', 42 | 'CVE', 43 | 'KYD', 44 | 'XOF', 45 | 'XAF', 46 | 'XPF', 47 | 'CLP', 48 | 'CNY', 49 | 'CNH', 50 | 'COP', 51 | 'KMF', 52 | 'CDF', 53 | 'CRC', 54 | 'CUP', 55 | 'CZK', 56 | 'DKK', 57 | 'DJF', 58 | 'DOP', 59 | 'XCD', 60 | 'EGP', 61 | 'SVC', 62 | 'ETB', 63 | 'EUR', 64 | 'FKP', 65 | 'FJD', 66 | 'GMD', 67 | 'GEL', 68 | 'GHS', 69 | 'GIP', 70 | 'GBP', 71 | 'GTQ', 72 | 'GNF', 73 | 'GYD', 74 | 'HTG', 75 | 'HNL', 76 | 'HKD', 77 | 'HUF', 78 | 'ISK', 79 | 'INR', 80 | 'IDR', 81 | 'IQD', 82 | 'ILS', 83 | 'JMD', 84 | 'JPY', 85 | 'JOD', 86 | 'KZT', 87 | 'KES', 88 | 'KWD', 89 | 'KGS', 90 | 'LAK', 91 | 'LBP', 92 | 'LSL', 93 | 'LRD', 94 | 'LYD', 95 | 'MOP', 96 | 'MKD', 97 | 'MGA', 98 | 'MWK', 99 | 'MYR', 100 | 'MVR', 101 | 'MRU', 102 | 'MUR', 103 | 'MXN', 104 | 'MDL', 105 | 'MNT', 106 | 'MAD', 107 | 'MZN', 108 | 'MMK', 109 | 'NAD', 110 | 'NPR', 111 | 'ANG', 112 | 'NZD', 113 | 'NIO', 114 | 'NGN', 115 | 'NOK', 116 | 'OMR', 117 | 'PKR', 118 | 'PAB', 119 | 'PGK', 120 | 'PYG', 121 | 'PEN', 122 | 'PHP', 123 | 'PLN', 124 | 'QAR', 125 | 'RON', 126 | 'RUB', 127 | 'RWF', 128 | 'SHP', 129 | 'WST', 130 | 'STN', 131 | 'SAR', 132 | 'RSD', 133 | 'SCR', 134 | 'SLE', 135 | 'SGD', 136 | 'SBD', 137 | 'SOS', 138 | 'ZAR', 139 | 'KRW', 140 | 'SSP', 141 | 'LKR', 142 | 'SDG', 143 | 'SRD', 144 | 'SZL', 145 | 'SEK', 146 | 'CHF', 147 | 'TWD', 148 | 'TJS', 149 | 'TZS', 150 | 'THB', 151 | 'TOP', 152 | 'TTD', 153 | 'TND', 154 | 'TRY', 155 | 'TMT', 156 | 'UGX', 157 | 'UAH', 158 | 'AED', 159 | 'USD', 160 | 'UYU', 161 | 'UZS', 162 | 'VUV', 163 | 'VES', 164 | 'VND', 165 | 'YER', 166 | 'ZMW', 167 | 'ZWL', 168 | ]; 169 | 170 | export default class mastercardFXM extends fxManager { 171 | ableToGetAllFXRate: boolean = false; 172 | 173 | public get fxRateList() { 174 | const fxRateList: fxManager['_fxRateList'] = {} as any; 175 | 176 | currenciesList.forEach((from) => { 177 | const _from = from == 'CNH' ? 'CNY' : from; 178 | 179 | fxRateList[from] = {} as any; 180 | currenciesList.forEach((to) => { 181 | const _to = to == 'CNH' ? 'CNY' : to; 182 | 183 | const currency = new Proxy( 184 | {}, 185 | { 186 | get: (_obj, prop) => { 187 | if ( 188 | ![ 189 | 'cash', 190 | 'remit', 191 | 'middle', 192 | 'updated', 193 | ].includes(prop.toString()) 194 | ) { 195 | return undefined; 196 | } 197 | 198 | if (!cache.has(`${_from}${_to}`)) { 199 | const request = syncRequest( 200 | 'GET', 201 | `https://www.mastercard.co.uk/settlement/currencyrate/conversion-rate?fxDate=0000-00-00&transCurr=${_to}&crdhldBillCurr=${_from}&bankFee=0&transAmt=1`, 202 | { 203 | headers: { 204 | 'user-agent': 205 | process.env[ 206 | 'HEADER_USER_AGENT' 207 | ] ?? 'fxrate axios/latest', 208 | }, 209 | }, 210 | ); 211 | cache.set( 212 | `${_from}${_to}`, 213 | request.getBody().toString(), 214 | ); 215 | } 216 | 217 | if ( 218 | ['cash', 'remit', 'middle'].includes( 219 | prop.toString(), 220 | ) 221 | ) { 222 | const data = JSON.parse( 223 | cache.get(`${_from}${_to}`), 224 | ); 225 | return divide( 226 | fraction(data.data.transAmt), 227 | fraction(data.data.conversionRate), 228 | ); 229 | } else { 230 | const data = JSON.parse( 231 | cache.get(`${_from}${_to}`), 232 | ); 233 | return new Date(data.data.fxDate); 234 | } 235 | }, 236 | }, 237 | ); 238 | fxRateList[from][to] = currency; 239 | }); 240 | }); 241 | 242 | return fxRateList; 243 | } 244 | 245 | public async getfxRateList(from: currency, to: currency) { 246 | const _from = from == 'CNH' ? 'CNY' : from; 247 | const _to = to == 'CNH' ? 'CNY' : to; 248 | 249 | if ( 250 | !( 251 | currenciesList.includes(from as string) && 252 | currenciesList.includes(to as string) 253 | ) 254 | ) { 255 | throw new Error('Currency not supported'); 256 | } 257 | 258 | if (cache.has(`${_from}${_to}`)) { 259 | return this.fxRateList[from][to]; 260 | } 261 | 262 | const req = await axios.get( 263 | `https://www.mastercard.co.uk/settlement/currencyrate/conversion-rate?fxDate=0000-00-00&transCurr=${_to}&crdhldBillCurr=${_from}&bankFee=0&transAmt=1`, 264 | { 265 | headers: { 266 | 'User-Agent': 267 | process.env['HEADER_USER_AGENT'] ?? 268 | 'fxrate axios/latest', 269 | }, 270 | }, 271 | ); 272 | 273 | const data = req.data; 274 | cache.set(`${_from}${_to}`, JSON.stringify(data)); 275 | 276 | return this.fxRateList[from][to]; 277 | } 278 | 279 | constructor() { 280 | super([]); 281 | } 282 | 283 | public update(): void { 284 | throw new Error('Method is deprecated'); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/FXGetter/visa.ts: -------------------------------------------------------------------------------- 1 | import fxManager from '../fxm/fxManager'; 2 | import syncRequest from 'sync-request'; 3 | import axios from 'axios'; 4 | 5 | import { fraction } from 'mathjs'; 6 | 7 | import { LRUCache } from 'lru-cache'; 8 | import { currency } from 'src/types'; 9 | 10 | import dayjs from 'dayjs'; 11 | 12 | import utc from 'dayjs/plugin/utc'; 13 | 14 | dayjs.extend(utc); 15 | 16 | const cache = new LRUCache({ 17 | max: 500, 18 | ttl: 1000 * 60 * 30, 19 | ttlAutopurge: true, 20 | }); 21 | 22 | const headers = { 23 | accept: 'application/json, text/plain, */*', 24 | 'accept-language': 'en,zh-CN;q=0.9,zh;q=0.8', 25 | 'sec-ch-ua': 26 | '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"', 27 | 'sec-ch-ua-mobile': '?0', 28 | 'sec-ch-ua-platform': '"Linux"', 29 | 'sec-fetch-dest': 'empty', 30 | 'sec-fetch-mode': 'cors', 31 | 'sec-fetch-site': 'same-origin', 32 | Referer: 33 | 'https://usa.visa.com/support/consumer/travel-support/exchange-rate-calculator.html', 34 | 'Referrer-Policy': 'no-referrer-when-downgrade', 35 | }; 36 | 37 | const currenciesList: string[] = [ 38 | 'AED', 39 | 'AFN', 40 | 'ALL', 41 | 'AMD', 42 | 'ANG', 43 | 'AOA', 44 | 'ARS', 45 | 'AUD', 46 | 'AWG', 47 | 'AZN', 48 | 'BAM', 49 | 'BBD', 50 | 'BDT', 51 | 'BGN', 52 | 'BHD', 53 | 'BIF', 54 | 'BMD', 55 | 'BND', 56 | 'BOB', 57 | 'BRL', 58 | 'BSD', 59 | 'BTN', 60 | 'BWP', 61 | 'BYN', 62 | 'BZD', 63 | 'CAD', 64 | 'CDF', 65 | 'CHF', 66 | 'CLP', 67 | 'CNY', 68 | 'CNH', 69 | 'COP', 70 | 'CRC', 71 | 'CVE', 72 | 'CYP', 73 | 'CZK', 74 | 'DJF', 75 | 'DKK', 76 | 'DOP', 77 | 'DZD', 78 | 'EEK', 79 | 'EGP', 80 | 'ERN', 81 | 'ETB', 82 | 'EUR', 83 | 'FJD', 84 | 'FKP', 85 | 'GBP', 86 | 'GEL', 87 | 'GHS', 88 | 'GIP', 89 | 'GMD', 90 | 'GNF', 91 | 'GQE', 92 | 'GTQ', 93 | 'GWP', 94 | 'GYD', 95 | 'HKD', 96 | 'HNL', 97 | 'HRK', 98 | 'HTG', 99 | 'HUF', 100 | 'IDR', 101 | 'ILS', 102 | 'INR', 103 | 'IQD', 104 | 'IRR', 105 | 'ISK', 106 | 'JMD', 107 | 'JOD', 108 | 'JPY', 109 | 'KES', 110 | 'KGS', 111 | 'KHR', 112 | 'KMF', 113 | 'KRW', 114 | 'KWD', 115 | 'KYD', 116 | 'KZT', 117 | 'LAK', 118 | 'LBP', 119 | 'LKR', 120 | 'LRD', 121 | 'LSL', 122 | 'LTL', 123 | 'LVL', 124 | 'LYD', 125 | 'MAD', 126 | 'MDL', 127 | 'MGA', 128 | 'MKD', 129 | 'MMK', 130 | 'MNT', 131 | 'MOP', 132 | 'MRO', 133 | 'MRU', 134 | 'MTL', 135 | 'MUR', 136 | 'MVR', 137 | 'MWK', 138 | 'MXN', 139 | 'MYR', 140 | 'MZN', 141 | 'NAD', 142 | 'NGN', 143 | 'NIO', 144 | 'NOK', 145 | 'NPR', 146 | 'NZD', 147 | 'None', 148 | 'OMR', 149 | 'PAB', 150 | 'PEN', 151 | 'PGK', 152 | 'PHP', 153 | 'PKR', 154 | 'PLN', 155 | 'PYG', 156 | 'QAR', 157 | 'RON', 158 | 'RSD', 159 | 'RUB', 160 | 'RWF', 161 | 'SAR', 162 | 'SBD', 163 | 'SCR', 164 | 'SDG', 165 | 'SEK', 166 | 'SGD', 167 | 'SHP', 168 | 'SIT', 169 | 'SKK', 170 | 'SLL', 171 | 'SOS', 172 | 'SRD', 173 | 'SSP', 174 | 'STD', 175 | 'STN', 176 | 'SVC', 177 | 'SYP', 178 | 'SZL', 179 | 'THB', 180 | 'TJS', 181 | 'TMT', 182 | 'TND', 183 | 'TOP', 184 | 'TRY', 185 | 'TTD', 186 | 'TWD', 187 | 'TZS', 188 | 'UAH', 189 | 'UGX', 190 | 'USD', 191 | 'UYU', 192 | 'UZS', 193 | 'VEF', 194 | 'VES', 195 | 'VND', 196 | 'VUV', 197 | 'WST', 198 | 'XAF', 199 | 'XCD', 200 | 'XOF', 201 | 'XPF', 202 | 'YER', 203 | 'ZAR', 204 | 'ZMW', 205 | 'ZWL', 206 | ]; 207 | 208 | export default class visaFXM extends fxManager { 209 | ableToGetAllFXRate: boolean = false; 210 | 211 | public get fxRateList() { 212 | const fxRateList: fxManager['_fxRateList'] = {} as any; 213 | 214 | currenciesList.forEach((from) => { 215 | fxRateList[from] = {} as any; 216 | currenciesList.forEach((to) => { 217 | const _from = from == 'CNH' ? 'CNY' : from; 218 | const _to = to == 'CNH' ? 'CNY' : to; 219 | 220 | const currency = new Proxy( 221 | {}, 222 | { 223 | get: (_obj, prop) => { 224 | if ( 225 | ![ 226 | 'cash', 227 | 'remit', 228 | 'middle', 229 | 'updated', 230 | ].includes(prop.toString()) 231 | ) { 232 | return undefined; 233 | } 234 | 235 | const dateString = dayjs() 236 | .utc() 237 | .format('MM/DD/YYYY'); 238 | 239 | if (!cache.has(`${_from}${_to}`)) { 240 | const request = syncRequest( 241 | 'GET', 242 | `https://usa.visa.com/cmsapi/fx/rates?amount=1&fee=0&utcConvertedDate=${dateString}&exchangedate=${dateString}&fromCurr=${_to}&toCurr=${_from}`, 243 | { 244 | headers, 245 | }, 246 | ); 247 | cache.set( 248 | `${_from}${_to}`, 249 | request.getBody().toString(), 250 | ); 251 | } 252 | 253 | if ( 254 | ['cash', 'remit', 'middle'].includes( 255 | prop.toString(), 256 | ) 257 | ) { 258 | const data = JSON.parse( 259 | cache.get(`${_from}${_to}`), 260 | ); 261 | return fraction(data.originalValues.fxRateVisa); 262 | } else { 263 | const data = JSON.parse( 264 | cache.get(`${_from}${_to}`), 265 | ); 266 | return new Date( 267 | data.originalValues.lastUpdatedVisaRate * 268 | 1000, 269 | ); 270 | } 271 | }, 272 | }, 273 | ); 274 | fxRateList[from][to] = currency; 275 | }); 276 | }); 277 | 278 | return fxRateList; 279 | } 280 | 281 | public async getfxRateList(from: currency, to: currency) { 282 | const _from = from == 'CNH' ? 'CNY' : from; 283 | const _to = to == 'CNH' ? 'CNY' : to; 284 | 285 | if ( 286 | !( 287 | currenciesList.includes(from as string) && 288 | currenciesList.includes(to as string) 289 | ) 290 | ) { 291 | throw new Error('Currency not supported'); 292 | } 293 | 294 | if (cache.has(`${_from}${_to}`)) { 295 | return this.fxRateList[from][to]; 296 | } 297 | 298 | const dateString = dayjs().utc().format('MM/DD/YYYY'); 299 | 300 | const req = await axios.get( 301 | `https://usa.visa.com/cmsapi/fx/rates?amount=1&fee=0&utcConvertedDate=${dateString}&exchangedate=${dateString}&fromCurr=${_to}&toCurr=${_from}`, 302 | { 303 | headers, 304 | }, 305 | ); 306 | 307 | const data = req.data; 308 | cache.set(`${_from}${_to}`, JSON.stringify(data)); 309 | 310 | return this.fxRateList[from][to]; 311 | } 312 | 313 | constructor() { 314 | super([]); 315 | } 316 | 317 | public update(): void { 318 | throw new Error('Method is deprecated'); 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/fxm/fxManager.ts: -------------------------------------------------------------------------------- 1 | import { create, all, Fraction } from 'mathjs'; 2 | import { currency, FXRate, FXPath } from '../types'; 3 | 4 | const math = create(all, { 5 | number: 'Fraction', 6 | }); 7 | 8 | const { multiply, divide, fraction, add } = math; 9 | 10 | type FXRateType = { 11 | cash: Fraction; 12 | remit: Fraction; 13 | middle: Fraction; 14 | updated: Date; 15 | }; 16 | 17 | export default class fxManager { 18 | private _fxRateList: { 19 | [currency in keyof currency]: { 20 | [currency in keyof currency]: FXRateType; 21 | }; 22 | } = {} as any; 23 | 24 | public get fxRateList() { 25 | const fxRateList = new Proxy(this._fxRateList, { 26 | get: function (target, prop) { 27 | let child = target[prop]; 28 | 29 | if (prop == 'CNY' && !('CNY' in target)) { 30 | if ('CNH' in target) { 31 | child = target['CNH']; 32 | } 33 | } 34 | 35 | if (!child) { 36 | return undefined; 37 | } 38 | 39 | return new Proxy(child, { 40 | get: function (target, prop) { 41 | let child = target[prop]; 42 | 43 | if (prop == 'CNY' && !('CNY' in target)) { 44 | if ('CNH' in target) { 45 | child = target['CNH']; 46 | } 47 | } 48 | 49 | return child; 50 | }, 51 | }); 52 | }, 53 | }); 54 | 55 | return fxRateList; 56 | } 57 | 58 | public set fxRateList(value) { 59 | this._fxRateList = value; 60 | } 61 | 62 | public async getfxRateList( 63 | from: currency, 64 | to: currency, 65 | ): Promise { 66 | return this.fxRateList[from][to]; 67 | } 68 | 69 | public async setfxRateList( 70 | from: currency, 71 | to: currency, 72 | value: { 73 | cash: Fraction; 74 | remit: Fraction; 75 | middle: Fraction; 76 | updated: Date; 77 | }, 78 | ) { 79 | this.fxRateList[from][to] = value; 80 | } 81 | 82 | ableToGetAllFXRate: boolean = true; 83 | 84 | constructor(FXRates: FXRate[]) { 85 | FXRates.sort().forEach((fxRate) => { 86 | try { 87 | this.update(fxRate); 88 | } catch (e) { 89 | console.error(e, fxRate); 90 | } 91 | }); 92 | return this; 93 | } 94 | 95 | public update(FXRate: FXRate): void { 96 | if (FXRate === null) return; 97 | 98 | const { currency, unit } = FXRate; 99 | let { rate } = FXRate; 100 | 101 | let { from, to } = currency; 102 | 103 | if (from == ('RMB' as currency.RMB)) from = 'CNY' as currency.CNY; 104 | if (to == ('RMB' as currency.RMB)) to = 'CNY' as currency.CNY; 105 | 106 | if (this.fxRateList[from] && this.fxRateList[from][to]) { 107 | if (this.fxRateList[from][to].updated > FXRate.updated) return; 108 | } 109 | 110 | if (!rate.buy && !rate.sell && !rate.middle) { 111 | console.log(FXRate); 112 | throw new Error('Invalid FXRate'); 113 | } 114 | 115 | if (!rate.buy && !rate.sell) { 116 | rate = { 117 | buy: { 118 | cash: rate.middle, 119 | remit: rate.middle, 120 | }, 121 | sell: { 122 | cash: rate.middle, 123 | remit: rate.middle, 124 | }, 125 | middle: rate.middle, 126 | }; 127 | } else if (!rate.buy && rate.sell) { 128 | rate.buy = rate.sell; 129 | } else if (!rate.sell && rate.buy) { 130 | rate.sell = rate.buy; 131 | } 132 | 133 | if (!rate.middle) { 134 | rate.middle = divide( 135 | add( 136 | math.min( 137 | rate.buy.cash || Infinity, 138 | rate.buy.remit || Infinity, 139 | rate.sell.cash || Infinity, 140 | rate.sell.remit || Infinity, 141 | ), 142 | math.max( 143 | rate.buy.cash || -Infinity, 144 | rate.buy.remit || -Infinity, 145 | rate.sell.cash || -Infinity, 146 | rate.sell.remit || -Infinity, 147 | ), 148 | ), 149 | 2, 150 | ) as Fraction; 151 | } 152 | 153 | if (!this.fxRateList[from]) { 154 | this.fxRateList[from] = { 155 | [from]: { 156 | cash: fraction(1), 157 | remit: fraction(1), 158 | middle: fraction(1), 159 | updated: new Date(`1970-1-1 00:00:00 UTC`), 160 | }, 161 | }; 162 | } 163 | this.fxRateList[from][to] = { 164 | middle: divide(fraction(rate.middle), unit), 165 | updated: FXRate.updated, 166 | }; 167 | if (!this.fxRateList[to]) { 168 | this.fxRateList[to] = { 169 | [to]: { 170 | cash: fraction(1), 171 | remit: fraction(1), 172 | middle: fraction(1), 173 | updated: new Date(`1970-1-1 00:00:00 UTC`), 174 | }, 175 | }; 176 | } 177 | this.fxRateList[to][from] = { 178 | middle: divide(unit, fraction(rate.middle)), 179 | updated: FXRate.updated, 180 | }; 181 | 182 | if (rate.buy.cash) { 183 | this.fxRateList[from][to].cash = divide( 184 | fraction(rate.buy.cash), 185 | unit, 186 | ); 187 | } 188 | 189 | if (rate.sell.cash) { 190 | this.fxRateList[to][from].cash = divide( 191 | unit, 192 | fraction(rate.sell.cash), 193 | ); 194 | } 195 | 196 | if (rate.buy.remit) { 197 | this.fxRateList[from][to].remit = divide( 198 | fraction(rate.buy.remit), 199 | unit, 200 | ); 201 | } 202 | 203 | if (rate.sell.remit) { 204 | this.fxRateList[to][from].remit = divide( 205 | unit, 206 | fraction(rate.sell.remit), 207 | ); 208 | } 209 | } 210 | 211 | private async convertDirect( 212 | from: currency, 213 | to: currency, 214 | type: 'cash' | 'remit' | 'middle', 215 | amount: number | Fraction, 216 | reverse: boolean = false, 217 | ): Promise { 218 | if (!(await this.getfxRateList(from, to))[type]) { 219 | throw new Error( 220 | `FX Path from ${from} to ${to} not support ${type} now`, 221 | ); 222 | } 223 | if (reverse) { 224 | return divide( 225 | fraction(amount), 226 | (await this.fxRateList[from][to])[type], 227 | ) as unknown as Fraction; 228 | } 229 | return multiply( 230 | (await this.fxRateList[from][to])[type], 231 | fraction(amount), 232 | ) as unknown as Fraction; 233 | } 234 | 235 | async getFXPath(from: currency, to: currency): Promise { 236 | const FXPath = { 237 | from, 238 | end: to, 239 | path: [], 240 | } as FXPath; 241 | 242 | if (from === to) { 243 | FXPath.path.push(from); 244 | return FXPath; 245 | } 246 | if (this.fxRateList[from][to]) { 247 | FXPath.path.push(to); 248 | return FXPath; 249 | } 250 | if (!this.fxRateList[from] || !this.fxRateList[to]) { 251 | throw new Error('Invalid currency'); 252 | } 253 | const queue: { currency: currency; path: currency[] }[] = []; 254 | const visited: currency[] = []; 255 | 256 | queue.push({ currency: from, path: [from] }); 257 | 258 | while (queue.length > 0) { 259 | const { currency, path } = queue.shift()!; 260 | visited.push(currency); 261 | 262 | if (currency === to) { 263 | FXPath.path = path; 264 | return FXPath; 265 | } 266 | 267 | const neighbors = Object.keys( 268 | this.fxRateList[currency], 269 | ) as currency[]; 270 | for (const neighbor of neighbors) { 271 | if (!visited.includes(neighbor)) { 272 | queue.push({ 273 | currency: neighbor, 274 | path: [...path, neighbor], 275 | }); 276 | } 277 | } 278 | } 279 | 280 | throw new Error('No FX path found between ' + from + ' and ' + to); 281 | } 282 | 283 | async convert( 284 | from: currency, 285 | to: currency, 286 | type: 'cash' | 'remit' | 'middle', 287 | amount: number, 288 | reverse: boolean = false, 289 | ): Promise { 290 | const FXPath = await this.getFXPath(from, to); 291 | if (reverse) FXPath.path = FXPath.path.reverse(); 292 | 293 | let current = from; 294 | let result = fraction(amount); 295 | 296 | try { 297 | for (const next of FXPath.path) { 298 | result = await this.convertDirect( 299 | current, 300 | next, 301 | type, 302 | result, 303 | reverse, 304 | ); 305 | current = next; 306 | } 307 | } catch (e) { 308 | throw new Error( 309 | `Cannot convert from ${from} to ${to} with ${type}: \n${e.message}`, 310 | ); 311 | } 312 | 313 | return result; 314 | } 315 | 316 | public async getUpdatedDate(from: currency, to: currency): Promise { 317 | if (!(await this.fxRateList[from][to])) { 318 | throw new Error(`FX Path from ${from} to ${to} not found`); 319 | } 320 | return (await this.fxRateList[from][to]).updated; 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/fxmManager.ts: -------------------------------------------------------------------------------- 1 | import { router, response, request, handler, interfaces } from 'handlers.js'; 2 | import fxManager from './fxm/fxManager'; 3 | import { FXRate, JSONRPCMethods, currency } from './types'; 4 | 5 | import { round, multiply, Fraction } from 'mathjs'; 6 | 7 | import process from 'node:process'; 8 | 9 | import JSONRPCRouter from 'handlers.js-jsonrpc'; 10 | 11 | export const useBasic = (response: response): void => { 12 | response.status = 200; 13 | response.headers.set('Date', new Date().toUTCString()); 14 | 15 | if (process.env.ENABLE_CORS) { 16 | response.headers.set( 17 | 'Access-Control-Allow-Origin', 18 | process.env.CORS_ORIGIN || '*', 19 | ); 20 | response.headers.set( 21 | 'Access-Control-Allow-Methods', 22 | 'GET, POST, OPTIONS', 23 | ); 24 | response.headers.set('Allow', 'GET, POST, OPTIONS'); 25 | response.headers.set( 26 | 'Access-Control-Expose-Headers', 27 | 'Date, X-License, X-Author, X-Powered-By', 28 | ); 29 | } 30 | }; 31 | 32 | export const useInternalRestAPI = async (url: string, router: router) => { 33 | const restResponse = await router 34 | .respond( 35 | new request( 36 | 'GET', 37 | new URL(`http://this.internal/${url}`), 38 | new interfaces.headers({}), 39 | '', 40 | {}, 41 | ), 42 | ) 43 | .catch((e) => e); 44 | 45 | try { 46 | return JSON.parse(restResponse.body); 47 | } catch (_e) { 48 | if (!(restResponse instanceof response)) throw new Error(restResponse); 49 | return restResponse; 50 | } 51 | }; 52 | 53 | const sortObject = (obj: unknown): any => { 54 | if (obj instanceof Array) { 55 | return obj.sort(); 56 | } 57 | if (typeof obj !== 'object') { 58 | return obj; 59 | } 60 | const keys = Object.keys(obj).sort(), 61 | sortedObj = {}; 62 | 63 | for (const key of keys) { 64 | sortedObj[key] = sortObject(obj[key]); 65 | } 66 | 67 | return sortedObj; 68 | }; 69 | 70 | const useJson = (response: response, request: request): void => { 71 | useBasic(response); 72 | 73 | const answer = JSON.parse(response.body); 74 | const sortedAnswer = sortObject(answer); 75 | 76 | response.body = JSON.stringify(sortedAnswer); 77 | 78 | if ( 79 | request.query.has('pretty') || 80 | request.headers.get('Sec-Fetch-Dest') === 'document' 81 | ) { 82 | response.body = JSON.stringify(sortedAnswer, null, 4); 83 | } 84 | 85 | response.headers.set('Content-type', 'application/json; charset=utf-8'); 86 | }; 87 | 88 | const getConvert = async ( 89 | from: currency, 90 | to: currency, 91 | type: string, 92 | fxManager: fxManager, 93 | request: request, 94 | amount: number = 100, 95 | fees: number = 0, 96 | ) => { 97 | let answer = await fxManager.convert( 98 | from, 99 | to, 100 | type as 'cash' | 'remit' | 'middle', 101 | Number(request.query.get('amount')) || amount || 100, 102 | request.query.has('reverse'), 103 | ); 104 | answer = multiply( 105 | answer, 106 | 1 + (Number(request.query.get('fees')) || fees) / 100, 107 | ) as Fraction; 108 | answer = 109 | Number(request.query.get('precision')) !== -1 110 | ? round(answer, Number(request.query.get('precision')) || 5) 111 | : answer; 112 | return Number(answer.toString()) || answer.toString(); 113 | }; 114 | 115 | const getDetails = async ( 116 | from: currency, 117 | to: currency, 118 | fxManager: fxManager, 119 | request: request, 120 | ) => { 121 | const result = { 122 | updated: (await fxManager.getUpdatedDate(from, to)).toUTCString(), 123 | }; 124 | for (const type of ['cash', 'remit', 'middle']) { 125 | try { 126 | result[type] = await getConvert(from, to, type, fxManager, request); 127 | } catch (_e) { 128 | result[type] = false; 129 | } 130 | } 131 | return result; 132 | }; 133 | 134 | class fxmManager extends JSONRPCRouter { 135 | private fxms: { 136 | [source: string]: fxManager; 137 | } = {}; 138 | 139 | private fxmStatus: { 140 | [source: string]: 'ready' | 'pending'; 141 | } = {}; 142 | 143 | private fxRateGetter: { 144 | [source: string]: (fxmManager?: fxmManager) => Promise; 145 | } = {}; 146 | 147 | public intervalIDs: { 148 | key: { timeout: NodeJS.Timeout; refreshDate: Date }; 149 | } = {} as any; 150 | 151 | protected rpcHandlers = { 152 | instanceInfo: () => useInternalRestAPI('info', this), 153 | 154 | listCurrencies: ({ source }) => { 155 | if (!source) throw new Error('source is required.'); 156 | 157 | return useInternalRestAPI(`${source}/`, this).then( 158 | (k) => 159 | new Object({ 160 | currency: k.currency, 161 | date: k.date, 162 | }), 163 | ); 164 | }, 165 | 166 | listFXRates: ({ 167 | source, 168 | from, 169 | precision = 2, 170 | amount = 100, 171 | fees = 0, 172 | reverse = false, 173 | }) => { 174 | if (!source) throw new Error('source is required.'); 175 | if (!from) throw new Error('from is required.'); 176 | 177 | return useInternalRestAPI( 178 | `${source}/${from}?precision=${precision}&amount=${amount}&fees=${fees}${reverse ? '&reverse' : ''}`, 179 | this, 180 | ); 181 | }, 182 | 183 | getFXRate: ({ 184 | source, 185 | from, 186 | to, 187 | type, 188 | precision = 2, 189 | amount = 100, 190 | fees = 0, 191 | reverse = false, 192 | }) => { 193 | if (!source) throw new Error('source is required.'); 194 | if (!from) throw new Error('from is required.'); 195 | if (!to) throw new Error('to is required.'); 196 | if (!type) throw new Error('type is required.'); 197 | if (type == 'all') type = ''; 198 | 199 | return useInternalRestAPI( 200 | `${source}/${from}/${to}/${type}?precision=${precision}&fees=${fees}${reverse ? '&reverse' : ''}&amount=${amount}`, 201 | this, 202 | ); 203 | }, 204 | }; 205 | 206 | constructor(sources: { [source: string]: () => Promise }) { 207 | super(); 208 | for (const source in sources) { 209 | this.registerGetter(source, sources[source]); 210 | } 211 | 212 | this.binding( 213 | '/info', 214 | this.create('GET', async (request: request) => { 215 | const rep = new response('', 200); 216 | rep.body = JSON.stringify({ 217 | status: 'ok', 218 | sources: Object.keys(this.fxms), 219 | version: `fxrate@${globalThis.GITBUILD || 'git'} ${globalThis.BUILDTIME || 'devlopment'}`, 220 | apiVersion: 'v1', 221 | environment: process.env.NODE_ENV || 'development', 222 | }); 223 | useJson(rep, request); 224 | return rep; 225 | }), 226 | ); 227 | 228 | this.enableList().mount(); 229 | this.log('JSONRPC is mounted.'); 230 | } 231 | 232 | public log(str: string) { 233 | if (process.env.LOG_LEVEL === 'error') return; 234 | setTimeout(() => { 235 | console.log(`[${new Date().toUTCString()}] [fxmManager] ${str}`); 236 | }, 0); 237 | } 238 | 239 | public has(source: string): boolean { 240 | return this.fxms[source] !== undefined; 241 | } 242 | 243 | public async updateFXManager(source: string): Promise { 244 | if (!this.has(source)) { 245 | throw new Error('Source not found'); 246 | } 247 | this.log(`${source} is updating...`); 248 | const fxRates = await this.fxRateGetter[source](this); 249 | fxRates.forEach((f) => this.fxms[source].update(f)); 250 | this.fxmStatus[source] = 'ready'; 251 | this.intervalIDs[source].refreshDate = new Date(); 252 | this.log(`${source} is updated, now is ready.`); 253 | return; 254 | } 255 | 256 | public async requestFXManager(source: string): Promise { 257 | if (this.fxmStatus[source] === 'pending') { 258 | await this.updateFXManager(source); 259 | } 260 | return this.fxms[source]; 261 | } 262 | 263 | public registerGetter( 264 | source: string, 265 | getter: () => Promise, 266 | ): void { 267 | this.fxms[source] = new fxManager([]); 268 | this.fxRateGetter[source] = getter; 269 | this.fxmStatus[source] = 'pending'; 270 | this.mountFXMRouter(source); 271 | this.log(`Registered ${source}.`); 272 | 273 | const refreshDate = new Date(); 274 | 275 | this.intervalIDs[source] = { 276 | timeout: setInterval( 277 | () => this.updateFXManager(source), 278 | 1000 * 60 * 30, 279 | ), 280 | refreshDate: refreshDate, 281 | }; 282 | } 283 | 284 | public registerFXM(source: string, fxManager: fxManager): void { 285 | this.fxms[source] = fxManager; 286 | this.fxmStatus[source] = 'ready'; 287 | this.mountFXMRouter(source); 288 | this.log(`Registered ${source}.`); 289 | } 290 | 291 | private mountFXMRouter(source: string): void { 292 | this.use([this.getFXMRouter(source)], `/${source}/(.*)`); 293 | this.use([this.getFXMRouter(source)], `/${source}`); 294 | } 295 | 296 | private getFXMRouter(source: string): router { 297 | const fxmRouter = new router(); 298 | 299 | const useCache = (response: response) => { 300 | response.headers.set( 301 | 'Cache-Control', 302 | `public, max-age=${ 303 | 30 * 60 - 304 | Math.round( 305 | Math.abs( 306 | (( 307 | this.intervalIDs[source] ?? { 308 | refreshDate: new Date(), 309 | } 310 | ).refreshDate.getTime() - 311 | new Date().getTime()) / 312 | 1000, 313 | ) % 1800, 314 | ) 315 | }`, 316 | ); 317 | }; 318 | 319 | const handlerSourceInfo = async ( 320 | request: request, 321 | response: response, 322 | ) => { 323 | if (request.params[0] && request.params[0] != source) { 324 | return response; 325 | } 326 | response.body = JSON.stringify({ 327 | status: 'ok', 328 | source, 329 | currency: Object.keys( 330 | (await this.requestFXManager(source)).fxRateList, 331 | ).sort(), 332 | date: new Date().toUTCString(), 333 | }); 334 | useJson(response, request); 335 | useCache(response); 336 | throw response; 337 | }; 338 | 339 | const handlerCurrencyAllFXRates = async ( 340 | request: request, 341 | response: response, 342 | ) => { 343 | if (request.params.from) 344 | request.params.from = request.params.from.toUpperCase(); 345 | 346 | const { from } = request.params; 347 | 348 | const result: { 349 | [to in keyof currency]: { 350 | [type in string]: string; 351 | }; 352 | } = {} as any; 353 | if (!(await this.requestFXManager(source)).ableToGetAllFXRate) { 354 | response.status = 403; 355 | result['status'] = 'error'; 356 | result['message'] = 357 | `Not able to get all FX rate with ${from} on ${source}`; 358 | response.body = JSON.stringify(result); 359 | useJson(response, request); 360 | return response; 361 | } 362 | for (const to in (await this.requestFXManager(source)).fxRateList[ 363 | from 364 | ]) { 365 | if (to == from) continue; 366 | result[to] = await getDetails( 367 | from as unknown as currency, 368 | to as unknown as currency, 369 | await this.requestFXManager(source), 370 | request, 371 | ); 372 | } 373 | response.body = JSON.stringify(result); 374 | useJson(response, request); 375 | useCache(response); 376 | return response; 377 | }; 378 | 379 | const handlerCurrencyConvert = async ( 380 | request: request, 381 | response: response, 382 | ) => { 383 | if (request.params.from) 384 | request.params.from = request.params.from.toUpperCase(); 385 | 386 | if (request.params.to) 387 | request.params.to = request.params.to.toUpperCase(); 388 | 389 | const { from, to } = request.params; 390 | const result = await getDetails( 391 | from as unknown as currency, 392 | to as unknown as currency, 393 | await this.requestFXManager(source), 394 | request, 395 | ); 396 | response.body = JSON.stringify(result); 397 | useJson(response, request); 398 | response.headers.set( 399 | 'Date', 400 | ( 401 | await ( 402 | await this.requestFXManager(source) 403 | ).getUpdatedDate( 404 | from as unknown as currency, 405 | to as unknown as currency, 406 | ) 407 | ).toUTCString(), 408 | ); 409 | useCache(response); 410 | 411 | return response; 412 | }; 413 | 414 | const handlerCurrencyConvertAmount = async ( 415 | request: request, 416 | response: response, 417 | ) => { 418 | if (request.params.from) 419 | request.params.from = request.params.from.toUpperCase(); 420 | 421 | if (request.params.to) 422 | request.params.to = request.params.to.toUpperCase(); 423 | 424 | const { from, to, type, amount } = request.params; 425 | const result = await getConvert( 426 | from as unknown as currency, 427 | to as unknown as currency, 428 | type, 429 | await this.requestFXManager(source), 430 | request, 431 | Number(amount), 432 | ); 433 | response.body = result.toString(); 434 | useBasic(response); 435 | response.headers.set( 436 | 'Date', 437 | ( 438 | await ( 439 | await this.requestFXManager(source) 440 | ).getUpdatedDate( 441 | from as unknown as currency.unknown, 442 | to as unknown as currency.unknown, 443 | ) 444 | ).toUTCString(), 445 | ); 446 | useCache(response); 447 | 448 | return response; 449 | }; 450 | 451 | fxmRouter.binding('/', new handler('GET', [handlerSourceInfo])); 452 | 453 | fxmRouter.binding( 454 | '/:from', 455 | new handler('GET', [handlerSourceInfo, handlerCurrencyAllFXRates]), 456 | ); 457 | 458 | fxmRouter.binding( 459 | '/:from/:to', 460 | new handler('GET', [handlerCurrencyConvert]), 461 | ); 462 | 463 | fxmRouter.binding( 464 | '/:from/:to/:type', 465 | new handler('GET', [handlerCurrencyConvertAmount]), 466 | ); 467 | 468 | fxmRouter.binding( 469 | '/:from/:to/:type/:amount', 470 | new handler('GET', [handlerCurrencyConvertAmount]), 471 | ); 472 | 473 | return fxmRouter; 474 | } 475 | 476 | public stopAllInterval(): void { 477 | for (const id in this.intervalIDs) { 478 | clearInterval(this.intervalIDs[id].timeout); 479 | } 480 | } 481 | } 482 | 483 | export default fxmManager; 484 | --------------------------------------------------------------------------------