Download data and create CSVs

This commit is contained in:
projectmoon 2024-11-10 22:21:09 +01:00
parent 3f884f0923
commit a8cfd41892
13 changed files with 1290 additions and 151 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules/
yarn-error.log
.parcel-cache/
dist/
src/**/**.js

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "none",
"tabWidth": 4,
"useTabs": false,
"printWidth": 90,
"semi": true
}

View File

@ -6,9 +6,15 @@
"@parcel/transformer-inline-string": "2.8.3",
"@types/firefox-webext-browser": "^111.0.0",
"@types/level-ttl": "^3.1.2",
"@types/papaparse": "^5.3.15",
"buffer": "^5.5.0",
"events": "^3.1.0",
"parcel": "^2.8.3"
"parcel": "^2.12.0",
"prettier": "^3.3.3",
"stream-browserify": "^3.0.0",
"string_decoder": "^1.3.0",
"typescript": "^5.6.3",
"util": "^0.12.3"
},
"scripts": {
"start": "parcel watch src/manifest.json --host localhost --config @parcel/config-webextension",
@ -27,6 +33,8 @@
"graphql-request": "^5.2.0",
"jsx-dom": "^8.0.5",
"level": "^8.0.0",
"nanoevents": "^7.0.1"
}
"nanoevents": "^7.0.1",
"papaparse": "^5.4.1"
},
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
}

View File

@ -1,66 +0,0 @@
const matchGraphQL = { urls: ["https://graphql.landsbankinn.is/v2"] };
import { fetchAccounts } from '../landsbankinn';
import { PartialStateUpdate } from '../state';
import { State } from '../state/level';
type HttpHeaders = browser.webRequest.HttpHeaders;
function extractAuthStateFromHeaders(headers: HttpHeaders): PartialStateUpdate {
let apiKey = '';
let authToken = '';
let fingerprintValue = '';
for (const header of headers) {
switch (header.name.toLowerCase()) {
case "authorization":
authToken = header.value || '';
break;
case "apikey":
apiKey = header.value || '';
break;
case "fingerprintvalue":
fingerprintValue = header.value || '';
break;
}
}
return { apiKey, authToken, fingerprintValue };
}
browser.webRequest.onBeforeSendHeaders.addListener(e => {
// TODO if request errors, then clear state and try again.
(async function() {
let 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 = extractAuthStateFromHeaders(e.requestHeaders);
state = await State.update(newState);
if (!newState.accounts) {
const accounts = await fetchAccounts(state);
await State.update({ accounts });
console.info('Acquired account data');
}
}
})();
}, matchGraphQL, ["requestHeaders"]);
export { }
// Here we intercept and record the necessary auth info. But what if
// we cannot do that, if user logs directly into a page where there
// are no graphql calls?
// We can get device fingerprint via content_script, and call session.
// Have a message called ENSURE_STATE.
// Sent to background page w/ device_fingerprint.
// If state not ready, call session endpoint, and parse output.
// Record state.
// Content script for ensure state runs everywhere.
// There is a value on the page that has everything we need!

View File

@ -1,9 +1,33 @@
import { isStatePopulated, isStateClear, PartialStateUpdate, ExporterState, isStateAbleToRequest } from "../state";
import Papa from 'papaparse';
import {
isStatePopulated,
isStateClear,
PartialStateUpdate,
ExporterState,
isStateAbleToRequest
} from '../state';
import { Status } from '../state';
import { LevelState } from '../state/level';
import * as landsbankinn from '../landsbankinn';
import { fetchAccounts } from "../landsbankinn";
import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsReply } from './messages';
import {
fetchAccounts,
downloadTransactions,
OutputFormat,
RawTransaction
} from '../landsbankinn';
import {
Actions,
DownloadTransactionReply,
DownloadTransactionsMessage,
EnsureStateMessage,
EnsureStateReply,
GetAccountsReply
} from './messages';
type GroupedRawTransaction = RawTransaction & { txnAccountNumber: string };
type EnsureStateAction = Required<Pick<EnsureStateMessage, 'clientState'>>;
type DownloadTransactionsAction = Omit<DownloadTransactionsMessage, 'action'>;
type TransactionsByAccount = Record<string, RawTransaction[] | undefined>;
// State singleton (move to its own file if we eventually get more
// than 1 background script)
@ -17,22 +41,28 @@ async function ensureDataPopulated(state: ExporterState) {
}
}
async function ensureState(message: EnsureStateMessage): Promise<EnsureStateReply> {
console.info('Ensuring state');
const clientState: PartialStateUpdate = message.clientState;;
async function ensureAuthState(message: EnsureStateAction) {
const clientState: PartialStateUpdate = message.clientState!;
let state = await State.current;
if (isStateClear(state)) {
await State.update({ status: Status.Updating });
const session = await landsbankinn.authSession();
await State.update({
status: Status.AbleToRequest, authToken: session.accessToken, ...clientState
status: Status.AbleToRequest,
authToken: session.accessToken,
...clientState
});
console.info('Updated auth state from client and auth session');
}
state = await State.current;
return await State.current;
}
async function ensureState(message: EnsureStateAction): Promise<EnsureStateReply> {
console.info('Ensuring state');
const state = await ensureAuthState(message);
await ensureDataPopulated(state);
return { state: 'Populated' };
}
@ -49,8 +79,7 @@ async function getAccounts(): Promise<GetAccountsReply> {
accounts: (await State.current).accounts,
error: null
};
}
else {
} else {
message = {
accounts: undefined,
error: new Error('state not yet populated')
@ -60,13 +89,58 @@ async function getAccounts(): Promise<GetAccountsReply> {
return message;
}
interface DownloadResult {
accountNumber: string;
transactions: landsbankinn.RawTransaction[];
}
const getAllTransactions = async (message: DownloadTransactionsAction) =>
await Promise.all(
message.accounts.map(async (accountNumber) => {
const transactions = (
await downloadTransactions({
accountNumber: accountNumber,
dateFrom: message.dateFrom,
dateTo: message.dateTo,
outputFormat: OutputFormat.CSV
})
).map((txn) => ({ txnAccountNumber: accountNumber, ...txn }));
return transactions;
})
);
const mapToCsv = (transactions: GroupedRawTransaction[]) => Papa.unparse(transactions);
async function downloadStatements(
message: DownloadTransactionsMessage
): Promise<DownloadTransactionReply> {
console.info('Initiating download for', message.accounts);
const state = await ensureAuthState({ clientState: await State.current });
await ensureDataPopulated(state);
// Should always be populated, but we can be careful anyway.
if (isStatePopulated(state)) {
const transactions = (await getAllTransactions(message)).flat();
return { csv: mapToCsv(transactions), error: null };
} else {
return {
csv: undefined,
error: new Error('state not yet populated')
};
}
}
browser.runtime.onMessage.addListener(async (message) => {
console.log('Received message:', message);
switch (message.action) {
case Actions.EnsureState:
return await ensureState(message);
return await ensureState(message as EnsureStateMessage);
case Actions.GetAccounts:
return await getAccounts();
case Actions.DownloadTransactions:
return await downloadStatements(message);
}
});
export { }
export {};

View File

@ -27,3 +27,15 @@ export interface EnsureStateMessage {
export interface EnsureStateReply {
state: 'Populated' | 'Error';
}
export interface DownloadTransactionsMessage {
action: Actions.DownloadTransactions;
accounts: string[];
dateFrom: Date;
dateTo: Date;
}
export interface DownloadTransactionReply {
csv: string | undefined;
error: Error | null;
}

View File

@ -1,14 +1,38 @@
// Functions for communicating with the background page.
import { PartialStateUpdate } from '../state';
import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsMessage, GetAccountsReply } from '../background/messages';
import {
Actions,
DownloadTransactionReply,
DownloadTransactionsMessage,
EnsureStateMessage,
EnsureStateReply,
GetAccountsMessage,
GetAccountsReply
} from '../background/messages';
export async function getAccounts(): Promise<GetAccountsReply> {
const message: GetAccountsMessage = { action: Actions.GetAccounts };
return await browser.runtime.sendMessage(message);
}
export async function ensureState(clientState: PartialStateUpdate): Promise<EnsureStateReply> {
export async function ensureState(
clientState: PartialStateUpdate
): Promise<EnsureStateReply> {
const message: EnsureStateMessage = { action: Actions.EnsureState, clientState };
return await browser.runtime.sendMessage(message);
}
export async function downloadTransactions(
accounts: string[],
dateFrom: Date,
dateTo: Date
): Promise<DownloadTransactionReply> {
const message: DownloadTransactionsMessage = {
accounts,
dateFrom,
dateTo,
action: Actions.DownloadTransactions
};
return await browser.runtime.sendMessage(message);
}

View File

@ -1,7 +1,7 @@
// Communicates data about the GraphQL URL and API key to the
// extension.
import { PartialStateUpdate } from "../state"; // TODO do not run state on content scripts
import { PartialStateUpdate } from '../state'; // TODO do not run state on content scripts
import { Event, ClientEvents } from './events';
import * as background from './background';
@ -13,7 +13,7 @@ interface ApiInformation {
}
interface RegularWindowProperties {
add_deviceprint(): string
add_deviceprint(): string;
LIPageData: ApiInformation;
}
@ -35,26 +35,32 @@ interface LIWindowMega extends Window {
}
type LIWindow = LIWindowRegular | LIWindowMega;
type AnyWindow = Window | LIWindowRegular | LIWindowMega
type AnyWindow = Window | LIWindowRegular | LIWindowMega;
function isApiInformation(liData: ApiInformation) {
return liData.graphQLUrl !== undefined &&
return (
liData.graphQLUrl !== undefined &&
liData.apikey !== undefined &&
liData.locale !== undefined;
liData.locale !== undefined
);
}
function isRegularWindow(win: AnyWindow): win is LIWindowRegular {
const liWindow = win as LIWindowRegular;
return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
return (
typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
liWindow.wrappedJSObject.LIPageData !== undefined &&
isApiInformation(liWindow.wrappedJSObject.LIPageData);
isApiInformation(liWindow.wrappedJSObject.LIPageData)
);
}
function isMegaHeaderWindow(win: AnyWindow): win is LIWindowRegular {
const liWindow = win as LIWindowMega;
return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
return (
typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
liWindow.wrappedJSObject.LIPageData !== undefined &&
isApiInformation(liWindow.wrappedJSObject.LIPageData.megaHeader);
isApiInformation(liWindow.wrappedJSObject.LIPageData.megaHeader)
);
}
// Type guard that coerces the global window object to the specialized
@ -66,7 +72,7 @@ function isLandsbankinnWindow(win: AnyWindow): win is LIWindow {
// Extract data from a page where the LIPageData field is prseent.
// Supports extraction from both the normal data structure found on
// most pages, and the "megaHeader" structure foud on the statements
// most pages, and the "megaHeader" structure found on the statements
// page.
function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined {
if (isRegularWindow(win)) {
@ -90,6 +96,7 @@ window.addEventListener('DOMContentLoaded', async () => {
const clientState = extractStateUpdate(window);
if (clientState) {
console.info('Acquired client state');
await background.ensureState(clientState);
ClientEvents.emit(Event.StatePopulated);
} else {

View File

@ -1,9 +1,10 @@
import React, { FormEventHandler } from 'jsx-dom'; // necessary for react to work
import * as React from 'jsx-dom'; // necessary for React to work?
import { Account } from '../../landsbankinn/models';
import $ from 'cash-dom';
interface AccountListProps {
accounts: Array<Account>;
}
const formatAccountNumber = (accountNumber: string) =>
@ -129,14 +130,19 @@ interface ExportDivProps {
fromLabel: string;
toLabel: string;
accounts: Account[];
onDownload: (accountNumbers: string[]) => void;
onDownload: (accountNumbers: string[], dateFrom: Date, dateTo: Date) => void;
}
export const ExportDiv = (props: ExportDivProps) => {
const submitHandler = (e: Event) => {
e.preventDefault();
const accountNumbers = getValues($('#account-export').get(0)!);
props.onDownload(accountNumbers);
const dateFrom = $(`#${datePickerID(props.fromLabel)}`).val() as string;
const dateTo = $(`#${datePickerID(props.toLabel)}`).val() as string;
console.log(dateFrom);
props.onDownload(accountNumbers, new Date(dateFrom), new Date(dateTo));
}
return (

View File

@ -1,14 +1,31 @@
import React from 'jsx-dom'; // necessary for react to work
import * as React from 'jsx-dom'; // necessary for react to work
import { Event, ClientEvents } from '../events';
import * as background from '../background';
import { ExportDiv } from './components';
const logDownload = (accountNumbers: string[], dateFrom: Date, dateTo: Date) =>
console.info(
`Downloading transactions from ${dateFrom} to ${dateTo} ` +
`for: ${accountNumbers.join(', ')}`
);
window.addEventListener('DOMContentLoaded', async () => {
ClientEvents.on(Event.StatePopulated, async () => {
const response = await background.getAccounts();
const downloadHandler = (accountNumbers: string[]) => {
console.log('downloading txns for', accountNumbers);
const downloadHandler = async (
accountNumbers: string[], dateFrom: Date, dateTo: Date
) => {
logDownload(accountNumbers, dateFrom, dateTo);
const reply = await background.downloadTransactions(
accountNumbers, dateFrom, dateTo
);
if (reply.error == null) {
console.log(reply.csv);
} else {
console.error(reply.error);
}
};
if (response.error == null) {

View File

@ -43,15 +43,20 @@ export class LevelState {
private async prepTTL() {
console.info(`Will clear state in ${Config.StateTTL} milliseconds`);
this.currentTTLHandle = window.setTimeout(async () => await this.clear(), Config.StateTTL);
this.currentTTLHandle = window.setTimeout(
async () => await this.clear(),
Config.StateTTL
);
}
async update(stateUpdate: ExporterState | PartialStateUpdate): Promise<ExporterState> {
async update(
stateUpdate: ExporterState | PartialStateUpdate
): Promise<ExporterState> {
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;
return await this.current;
}
}

View File

@ -2,10 +2,10 @@
"compilerOptions": {
"strict": true,
"experimentalDecorators": true,
"target": "es6",
"module": "es6",
"target": "es2022",
"module": "es2022",
"allowSyntheticDefaultImports": true,
"lib": [ "dom", "es6" ],
"lib": [ "dom", "es2022", "esnext" ],
"jsx": "react-jsx",
"jsxImportSource": "jsx-dom",
"esModuleInterop": true,

1135
yarn.lock

File diff suppressed because it is too large Load Diff