initial commit
This commit is contained in:
commit
10dc0401be
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
yarn-error.log
|
||||
.parcel-cache/
|
||||
dist/
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@parcel/config-webextension": "^2.8.3",
|
||||
"@parcel/transformer-graphql": "2.8.3",
|
||||
"@parcel/transformer-inline-string": "2.8.3",
|
||||
"@types/firefox-webext-browser": "^111.0.0",
|
||||
"@types/level-ttl": "^3.1.2",
|
||||
"buffer": "^5.5.0",
|
||||
"events": "^3.1.0",
|
||||
"parcel": "^2.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "parcel watch src/manifest.json --host localhost --config @parcel/config-webextension",
|
||||
"build": "parcel build src/manifest.json --no-content-hash --config @parcel/config-webextension",
|
||||
"clean": "rm -rf dist/ .parcel-cache/"
|
||||
},
|
||||
"@parcel/bundler-default": {
|
||||
"minBundles": 10000000,
|
||||
"minBundleSize": 3000,
|
||||
"maxParallelRequests": 20
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^2.29.3",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^5.2.0",
|
||||
"level": "^8.0.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
const matchGraphQL = { urls: ["https://graphql.landsbankinn.is/v2"] };
|
||||
|
||||
import { fetchAccounts } from './landsbankinn';
|
||||
import { PartialStateUpdate, State } from './state';
|
||||
|
||||
type HttpHeaders = browser.webRequest.HttpHeaders;
|
||||
|
||||
function extractFromHeaders(headers: HttpHeaders): PartialStateUpdate {
|
||||
let apiKey = '';
|
||||
let gqlAuthToken = '';
|
||||
let rsaFingerprint = '';
|
||||
|
||||
for (const header of headers) {
|
||||
switch (header.name.toLowerCase()) {
|
||||
case "authorization":
|
||||
gqlAuthToken = header.value || '';
|
||||
break;
|
||||
case "apikey":
|
||||
apiKey = header.value || '';
|
||||
break;
|
||||
case "rsa-fingerprint":
|
||||
rsaFingerprint = header.value || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { apiKey, gqlAuthToken, rsaFingerprint };
|
||||
}
|
||||
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(e => {
|
||||
// TODO if request errors, then clear state and try again.
|
||||
(async function() {
|
||||
const state = await State.current;
|
||||
if (!state.ready && e.requestHeaders) {
|
||||
console.info('Will update auth info from headers');
|
||||
|
||||
// Lock ready to true so we don't have multiple attempts
|
||||
// at getting account info.
|
||||
await State.update({ ready: true });
|
||||
let newState = extractFromHeaders(e.requestHeaders);
|
||||
|
||||
State.update(newState)
|
||||
.then(state => !state.accounts ? fetchAccounts() : state.accounts)
|
||||
.then(accounts => State.update({ accounts }))
|
||||
.then(() => console.log('done'));
|
||||
}
|
||||
})();
|
||||
}, matchGraphQL, ["requestHeaders"]);
|
||||
|
||||
export { }
|
|
@ -0,0 +1,33 @@
|
|||
import { setDefaultOptions, parse, format } from 'date-fns';
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import { fetchAccounts, OutputFormat, DownloadTransactionsRequest, downloadTransactions } from './landsbankinn';
|
||||
|
||||
setDefaultOptions({ locale: enUS });
|
||||
|
||||
|
||||
//First attempt hardcoded data
|
||||
async function doIt() {
|
||||
console.log('doing it');
|
||||
const from = parse('01 Jan 2023', 'dd MMM yyyy', new Date());
|
||||
console.log(from);
|
||||
const to = parse('31 Jan 2023', 'dd MMM yyyy', new Date());
|
||||
const req: DownloadTransactionsRequest = {
|
||||
accountNumber: '010126013792',
|
||||
dateFrom: from,
|
||||
dateTo: to,
|
||||
outputFormat: OutputFormat.CSV,
|
||||
};
|
||||
|
||||
const txns = await downloadTransactions(req);
|
||||
console.log(txns);
|
||||
}
|
||||
|
||||
//doIt().then(() => alert('did it'));
|
||||
|
||||
async function fetchIt() {
|
||||
console.log('fetching it');
|
||||
const data = fetchAccounts();
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
//fetchIt().then(() => alert('fetched it'));
|
|
@ -0,0 +1,30 @@
|
|||
export interface FetchAccountsResponse {
|
||||
viewer: Viewer;
|
||||
}
|
||||
|
||||
export interface Viewer {
|
||||
id: string;
|
||||
accounts: Accounts;
|
||||
}
|
||||
|
||||
export interface Accounts {
|
||||
nodes: Array<Node>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
accountNumber: string;
|
||||
name: string;
|
||||
kennitala: string;
|
||||
owner: string;
|
||||
state: string;
|
||||
isVaxtareikningur30: boolean;
|
||||
isNoticeAccount: boolean;
|
||||
currency: string;
|
||||
isCurrencyAccount: boolean;
|
||||
availableAmount: number;
|
||||
balance: number;
|
||||
orderedAmountAvailable: number;
|
||||
orderedAmountTotal: number;
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { gql } from 'graphql-request/build/esm';
|
||||
|
||||
// Currently not imported due to a bug with Parcel and the bundle-text
|
||||
// importer, and complications with running the query from the
|
||||
// imported text.
|
||||
|
||||
// Exports
|
||||
export const fetchAccounts = gql`
|
||||
query fetchAccounts($first: Int, $after: String, $filter: AccountFilterArgs, $orderBy: AccountOrderBy) {
|
||||
viewer {
|
||||
id
|
||||
accounts(first: $first, after: $after, filter: $filter, orderBy: $orderBy) {
|
||||
nodes {
|
||||
...AccountSelectorAccountParts
|
||||
__typename
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment AccountSelectorAccountParts on Account {
|
||||
id
|
||||
accountNumber
|
||||
name
|
||||
kennitala
|
||||
owner
|
||||
state
|
||||
isVaxtareikningur30
|
||||
isNoticeAccount
|
||||
currency
|
||||
isCurrencyAccount
|
||||
availableAmount
|
||||
balance
|
||||
orderedAmountAvailable
|
||||
orderedAmountTotal
|
||||
__typename
|
||||
}
|
||||
`
|
|
@ -0,0 +1,140 @@
|
|||
import { GraphQLClient } from 'graphql-request/build/esm';
|
||||
import { setDefaultOptions, format } from 'date-fns';
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import { RequestConfig } from 'graphql-request/build/esm/types';
|
||||
import { State } from './state';
|
||||
import * as queries from './graphql/queries';
|
||||
import { FetchAccountsResponse } from '~graphql/models';
|
||||
|
||||
setDefaultOptions({ locale: enUS });
|
||||
|
||||
const apiEndpoint = 'https://netbanki.landsbankinn.is/services/accounts/$accountID/transactions';
|
||||
|
||||
export enum TransactionFormat { JSON };
|
||||
export enum OutputFormat { CSV };
|
||||
|
||||
export interface DownloadTransactionsRequest {
|
||||
accountNumber: string;
|
||||
dateFrom: Date;
|
||||
dateTo: Date;
|
||||
outputFormat: OutputFormat;
|
||||
}
|
||||
|
||||
export interface RawTransaction {
|
||||
//txn id?
|
||||
id: string;
|
||||
//often null
|
||||
accountNumber: string | null;
|
||||
amount: Number;
|
||||
currencyCode: string;
|
||||
//ISO date
|
||||
bookingDate: string;
|
||||
//ISO date
|
||||
valueDate: string;
|
||||
|
||||
balanceAfterTransaction: Number;
|
||||
|
||||
displayText: string;
|
||||
|
||||
//how do these relate to id?
|
||||
reference: string;
|
||||
referenceNumber: string;
|
||||
|
||||
//how does it relate to the booking/value dates?
|
||||
//exact time of txn/
|
||||
timestamp: string;
|
||||
|
||||
textCodeDescription: string;
|
||||
payerDescription: string;
|
||||
|
||||
// e.g. Utteky med debitkorti
|
||||
description: String;
|
||||
|
||||
batchID: string;
|
||||
|
||||
// only for individuals?
|
||||
recipientKennitala: string;
|
||||
|
||||
recipientName: string;
|
||||
//probably some kind of txn category (central bank?)
|
||||
transactionCodekey: string;
|
||||
|
||||
bankBranch: string;
|
||||
debitCardWarrantyNumber: string | null;
|
||||
|
||||
//currency to ISK exchange rate
|
||||
exchangeRate: Number;
|
||||
|
||||
// what is displayed to the user in the table
|
||||
customerDescription: string;
|
||||
|
||||
//don't know what this could be.
|
||||
details: null;
|
||||
|
||||
}
|
||||
|
||||
//Sat Jan 03 2023
|
||||
const formatDate = (date: Date) => format(date, 'ccc LLL dd yyyy');
|
||||
|
||||
function getUrl(accountNumber: string, dateFrom: Date, dateTo: Date) {
|
||||
const url = `https://netbanki.landsbankinn.is/services/accounts/${accountNumber}/transactions/`;
|
||||
const from = encodeURIComponent(formatDate(dateFrom));
|
||||
const to = encodeURIComponent(formatDate(dateTo));
|
||||
const params = `?from=${from}&to=${to}`
|
||||
return url + params;
|
||||
}
|
||||
|
||||
export async function downloadTransactions(req: DownloadTransactionsRequest): Promise<Array<RawTransaction>> {
|
||||
const url = getUrl(req.accountNumber, req.dateFrom, req.dateTo);
|
||||
const options: RequestInit = {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-cache",
|
||||
referrerPolicy: "strict-origin-when-cross-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fetch(url, options);
|
||||
if (result.ok) {
|
||||
return await result.json();
|
||||
} else {
|
||||
//attempt to read json.
|
||||
const error = await result.json();
|
||||
if (error.hasOwnProperty('message')) {
|
||||
console.error(error['message']);
|
||||
}
|
||||
console.error(result);
|
||||
return new Array();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAccounts(): Promise<FetchAccountsResponse> {
|
||||
const state = await State.current;
|
||||
const options: RequestConfig = {
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
cache: "no-cache",
|
||||
referrerPolicy: "strict-origin-when-cross-origin",
|
||||
headers: {
|
||||
apikey: state.apiKey,
|
||||
authorization: state.gqlAuthToken,
|
||||
"rsa-fingerprint": state.rsaFingerprint
|
||||
},
|
||||
};
|
||||
|
||||
const graphQLClient = new GraphQLClient('https://graphql.landsbankinn.is/v2', options);
|
||||
|
||||
const variables = {
|
||||
"first": 100,
|
||||
"filter": {
|
||||
"includeClosed": true
|
||||
},
|
||||
};
|
||||
|
||||
const data: FetchAccountsResponse = await graphQLClient.request(queries.fetchAccounts, variables);
|
||||
//TODO transform janky graphql response into nicer data structure for us.
|
||||
return data;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Sample Web Extension",
|
||||
"version": "0.0.1",
|
||||
"permissions": [
|
||||
"https://netbanki.landsbankinn.is/",
|
||||
"https://graphql.landsbankinn.is/*",
|
||||
"webRequest",
|
||||
"webRequestBlocking"
|
||||
],
|
||||
"background": {
|
||||
"scripts": [ "background.ts" ],
|
||||
"persistent": false
|
||||
},
|
||||
"content_scripts": [{
|
||||
"matches": ["https://netbanki.landsbankinn.is/*", "https://graphql.landsbankinn.is/v2"],
|
||||
"js": ["content-script.ts"]
|
||||
}]
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
declare module 'bundle-text:*' {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import { Level } from 'level';
|
||||
import { FetchAccountsResponse } from '~graphql/models';
|
||||
|
||||
export interface ExporterState {
|
||||
apiKey: string;
|
||||
gqlAuthToken: string;
|
||||
rsaFingerprint: string;
|
||||
accounts: FetchAccountsResponse;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export interface PartialStateUpdate {
|
||||
apiKey?: string;
|
||||
gqlAuthToken?: string;
|
||||
rsaFingerprint?: string;
|
||||
accounts?: FetchAccountsResponse;
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
const FIVE_MINUTES = 5 * 60 * 1000;
|
||||
|
||||
type LevelError = Error | null | undefined;
|
||||
|
||||
//@ts-ignore
|
||||
const errorNotFound = (err: LevelError) => err.code && err.code == 'LEVEL_NOT_FOUND';
|
||||
|
||||
class LevelState {
|
||||
private _db = new Level('landsbankinn-exporter', { valueEncoding: 'json' });
|
||||
private currentTTLHandle: number | undefined = -1;
|
||||
|
||||
constructor() {
|
||||
function creator(err: Error | null | undefined) {
|
||||
if (err) {
|
||||
if (errorNotFound(err)) {
|
||||
console.info('Creating initial empty state');
|
||||
//@ts-ignore
|
||||
this._db.put('state', {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const self = this;
|
||||
this.prepTTL().then(() => self._db.get('state', {}, creator.bind(self)));
|
||||
}
|
||||
|
||||
get current(): Promise<ExporterState> {
|
||||
return this._db.get('state') as unknown as Promise<ExporterState>;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
console.info('Cleared state');
|
||||
return await this._db.put('state', {} as any);
|
||||
}
|
||||
|
||||
private async clearTTL() {
|
||||
if (this.currentTTLHandle) {
|
||||
window.clearTimeout(this.currentTTLHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private async prepTTL() {
|
||||
console.info("Will clear state in 5 minutes");
|
||||
this.currentTTLHandle = window.setTimeout(async () => await this.clear(), FIVE_MINUTES);
|
||||
}
|
||||
|
||||
async update(stateUpdate: ExporterState | PartialStateUpdate) {
|
||||
this.clearTTL();
|
||||
const currentState: ExporterState = await this.current;
|
||||
let newState = { ...currentState, ...stateUpdate };
|
||||
await this._db.put('state', newState as any);
|
||||
this.prepTTL();
|
||||
return this.current;
|
||||
}
|
||||
}
|
||||
|
||||
export const State: LevelState = new LevelState();
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "es6",
|
||||
"module": "es6",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": [ "dom", "es6" ],
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"jsxImportSource": "jsx-dom",
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
How to get it working:
|
||||
- [X] Landsbankinn API client for arbitrary transaction lists
|
||||
- [ ] Transform raw transactions into friendlier ones (i.e. date objects)
|
||||
- [ ] Content script that can run all the time and puts a button somewhere (statements tab, and extension icon)
|
||||
- [ ] Background page that downloads all the shit and combines into CSV, then 'downloads' file.
|
||||
- [ ] Graphql API client to get information about user, like listing accounts.
|
||||
- [ ] Get working client
|
||||
- [ ] Set API key
|
||||
- [ ] Set bearer token
|
||||
- [ ] Intercept bearer token by yoinking it out of a request
|
Loading…
Reference in New Issue