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 yarn-error.log
.parcel-cache/ .parcel-cache/
dist/ 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", "@parcel/transformer-inline-string": "2.8.3",
"@types/firefox-webext-browser": "^111.0.0", "@types/firefox-webext-browser": "^111.0.0",
"@types/level-ttl": "^3.1.2", "@types/level-ttl": "^3.1.2",
"@types/papaparse": "^5.3.15",
"buffer": "^5.5.0", "buffer": "^5.5.0",
"events": "^3.1.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": { "scripts": {
"start": "parcel watch src/manifest.json --host localhost --config @parcel/config-webextension", "start": "parcel watch src/manifest.json --host localhost --config @parcel/config-webextension",
@ -27,6 +33,8 @@
"graphql-request": "^5.2.0", "graphql-request": "^5.2.0",
"jsx-dom": "^8.0.5", "jsx-dom": "^8.0.5",
"level": "^8.0.0", "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 { Status } from '../state';
import { LevelState } from '../state/level'; import { LevelState } from '../state/level';
import * as landsbankinn from '../landsbankinn'; import * as landsbankinn from '../landsbankinn';
import { fetchAccounts } from "../landsbankinn"; import {
import { Actions, EnsureStateMessage, EnsureStateReply, GetAccountsReply } from './messages'; 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 // State singleton (move to its own file if we eventually get more
// than 1 background script) // than 1 background script)
@ -17,22 +41,28 @@ async function ensureDataPopulated(state: ExporterState) {
} }
} }
async function ensureState(message: EnsureStateMessage): Promise<EnsureStateReply> { async function ensureAuthState(message: EnsureStateAction) {
console.info('Ensuring state'); const clientState: PartialStateUpdate = message.clientState!;
const clientState: PartialStateUpdate = message.clientState;;
let state = await State.current; let state = await State.current;
if (isStateClear(state)) { if (isStateClear(state)) {
await State.update({ status: Status.Updating }); await State.update({ status: Status.Updating });
const session = await landsbankinn.authSession(); const session = await landsbankinn.authSession();
await State.update({ 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'); 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); await ensureDataPopulated(state);
return { state: 'Populated' }; return { state: 'Populated' };
} }
@ -49,8 +79,7 @@ async function getAccounts(): Promise<GetAccountsReply> {
accounts: (await State.current).accounts, accounts: (await State.current).accounts,
error: null error: null
}; };
} } else {
else {
message = { message = {
accounts: undefined, accounts: undefined,
error: new Error('state not yet populated') error: new Error('state not yet populated')
@ -60,13 +89,58 @@ async function getAccounts(): Promise<GetAccountsReply> {
return message; 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) => { browser.runtime.onMessage.addListener(async (message) => {
console.log('Received message:', message);
switch (message.action) { switch (message.action) {
case Actions.EnsureState: case Actions.EnsureState:
return await ensureState(message); return await ensureState(message as EnsureStateMessage);
case Actions.GetAccounts: case Actions.GetAccounts:
return await 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 { export interface EnsureStateReply {
state: 'Populated' | 'Error'; 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. // Functions for communicating with the background page.
import { PartialStateUpdate } from '../state'; 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> { export async function getAccounts(): Promise<GetAccountsReply> {
const message: GetAccountsMessage = { action: Actions.GetAccounts }; const message: GetAccountsMessage = { action: Actions.GetAccounts };
return await browser.runtime.sendMessage(message); 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 }; const message: EnsureStateMessage = { action: Actions.EnsureState, clientState };
return await browser.runtime.sendMessage(message); 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 // Communicates data about the GraphQL URL and API key to the
// extension. // 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 { Event, ClientEvents } from './events';
import * as background from './background'; import * as background from './background';
@ -13,7 +13,7 @@ interface ApiInformation {
} }
interface RegularWindowProperties { interface RegularWindowProperties {
add_deviceprint(): string add_deviceprint(): string;
LIPageData: ApiInformation; LIPageData: ApiInformation;
} }
@ -35,26 +35,32 @@ interface LIWindowMega extends Window {
} }
type LIWindow = LIWindowRegular | LIWindowMega; type LIWindow = LIWindowRegular | LIWindowMega;
type AnyWindow = Window | LIWindowRegular | LIWindowMega type AnyWindow = Window | LIWindowRegular | LIWindowMega;
function isApiInformation(liData: ApiInformation) { function isApiInformation(liData: ApiInformation) {
return liData.graphQLUrl !== undefined && return (
liData.graphQLUrl !== undefined &&
liData.apikey !== undefined && liData.apikey !== undefined &&
liData.locale !== undefined; liData.locale !== undefined
);
} }
function isRegularWindow(win: AnyWindow): win is LIWindowRegular { function isRegularWindow(win: AnyWindow): win is LIWindowRegular {
const liWindow = win as LIWindowRegular; const liWindow = win as LIWindowRegular;
return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' && return (
typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
liWindow.wrappedJSObject.LIPageData !== undefined && liWindow.wrappedJSObject.LIPageData !== undefined &&
isApiInformation(liWindow.wrappedJSObject.LIPageData); isApiInformation(liWindow.wrappedJSObject.LIPageData)
);
} }
function isMegaHeaderWindow(win: AnyWindow): win is LIWindowRegular { function isMegaHeaderWindow(win: AnyWindow): win is LIWindowRegular {
const liWindow = win as LIWindowMega; const liWindow = win as LIWindowMega;
return typeof liWindow.wrappedJSObject.add_deviceprint === 'function' && return (
typeof liWindow.wrappedJSObject.add_deviceprint === 'function' &&
liWindow.wrappedJSObject.LIPageData !== undefined && 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 // 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. // Extract data from a page where the LIPageData field is prseent.
// Supports extraction from both the normal data structure found on // 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. // page.
function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined { function extractStateUpdate(win: LIWindow): PartialStateUpdate | undefined {
if (isRegularWindow(win)) { if (isRegularWindow(win)) {
@ -90,6 +96,7 @@ window.addEventListener('DOMContentLoaded', async () => {
const clientState = extractStateUpdate(window); const clientState = extractStateUpdate(window);
if (clientState) { if (clientState) {
console.info('Acquired client state');
await background.ensureState(clientState); await background.ensureState(clientState);
ClientEvents.emit(Event.StatePopulated); ClientEvents.emit(Event.StatePopulated);
} else { } 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 { Account } from '../../landsbankinn/models';
import $ from 'cash-dom'; import $ from 'cash-dom';
interface AccountListProps { interface AccountListProps {
accounts: Array<Account>; accounts: Array<Account>;
} }
const formatAccountNumber = (accountNumber: string) => const formatAccountNumber = (accountNumber: string) =>
@ -129,14 +130,19 @@ interface ExportDivProps {
fromLabel: string; fromLabel: string;
toLabel: string; toLabel: string;
accounts: Account[]; accounts: Account[];
onDownload: (accountNumbers: string[]) => void; onDownload: (accountNumbers: string[], dateFrom: Date, dateTo: Date) => void;
} }
export const ExportDiv = (props: ExportDivProps) => { export const ExportDiv = (props: ExportDivProps) => {
const submitHandler = (e: Event) => { const submitHandler = (e: Event) => {
e.preventDefault(); e.preventDefault();
const accountNumbers = getValues($('#account-export').get(0)!); 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 ( 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 { Event, ClientEvents } from '../events';
import * as background from '../background'; import * as background from '../background';
import { ExportDiv } from './components'; 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 () => { window.addEventListener('DOMContentLoaded', async () => {
ClientEvents.on(Event.StatePopulated, async () => { ClientEvents.on(Event.StatePopulated, async () => {
const response = await background.getAccounts(); const response = await background.getAccounts();
const downloadHandler = (accountNumbers: string[]) => { const downloadHandler = async (
console.log('downloading txns for', accountNumbers); 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) { if (response.error == null) {

View File

@ -43,15 +43,20 @@ export class LevelState {
private async prepTTL() { private async prepTTL() {
console.info(`Will clear state in ${Config.StateTTL} milliseconds`); 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(); this.clearTTL();
const currentState: ExporterState = await this.current; const currentState: ExporterState = await this.current;
let newState = { ...currentState, ...stateUpdate }; let newState = { ...currentState, ...stateUpdate };
await this._db.put('state', newState as any); await this._db.put('state', newState as any);
this.prepTTL(); this.prepTTL();
return this.current; return await this.current;
} }
} }

View File

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

1135
yarn.lock

File diff suppressed because it is too large Load Diff