@accounter/scraper-app
Version:
Scraper app with Fastify server and React UI
121 lines (105 loc) • 3.65 kB
text/typescript
import { addMonths, format, startOfMonth } from 'date-fns';
import type { z } from 'zod';
import { init, type IsracardCardsTransactionsList } from '@accounter/modern-poalim-scraper';
import type { ServerMessage } from '../../shared/ws-protocol.js';
import { validatePayload } from '../validate-payload.js';
import type { IsracardAmexAccountSchema } from '../vault.js';
export type IsracardCreds = z.infer<typeof IsracardAmexAccountSchema>;
export type Emitter = (msg: ServerMessage) => void;
export type MonthlyIsracardPayload = {
month: string;
data: IsracardCardsTransactionsList;
};
function buildMonthList(dateFrom: Date, dateTo: Date): Date[] {
const months: Date[] = [];
let current = startOfMonth(dateFrom);
const end = startOfMonth(addMonths(dateTo, 1));
while (current <= end) {
months.push(current);
current = addMonths(current, 1);
}
return months;
}
function countIsracardTransactions(data: IsracardCardsTransactionsList): number {
const bean = data.CardsTransactionsListBean;
if (!bean) return 0;
return Object.values(bean).reduce((sum, entry) => {
if (
entry &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
'@AllCards' in entry &&
'CurrentCardTransactions' in entry
) {
let txnCount = 0;
for (const subEntry of Array.isArray(entry.CurrentCardTransactions)
? entry.CurrentCardTransactions
: []) {
if (!subEntry || typeof subEntry !== 'object' || Array.isArray(subEntry)) {
continue;
}
if ('txnIsrael' in subEntry) {
txnCount += subEntry.txnIsrael?.length ?? 0;
} else if ('txnAbroad' in subEntry) {
txnCount += subEntry.txnAbroad?.length ?? 0;
}
}
return sum + txnCount;
}
return sum;
}, 0);
}
export async function scrapeIsracard(
creds: IsracardCreds,
dateFrom: Date,
dateTo: Date,
emit: Emitter,
headless = true,
): Promise<MonthlyIsracardPayload[]> {
const { isracard: isracardFn, close } = await init({ headless });
try {
const scraper = await isracardFn(
{ ID: creds.ownerId, password: creds.password, card6Digits: creds.last6Digits },
{ validateSchema: true },
);
const months = buildMonthList(dateFrom, dateTo);
const results: MonthlyIsracardPayload[] = [];
for (const month of months) {
const monthStr = format(month, 'yyyy-MM');
emit({ type: 'task-month-fetching', sourceId: creds.id, month: monthStr });
try {
const { data, isValid } = await scraper.getMonthTransactions(month);
if (!data) continue;
if (!isValid) {
throw new Error(`Invalid Isracard data for ${monthStr}`);
}
if (data.Header?.Status !== '1') {
throw new Error(
`Isracard login/password issue (Header.Status=${data.Header?.Status}) for ${monthStr}`,
);
}
const validated = validatePayload('isracard', data);
const txnCount = countIsracardTransactions(validated);
emit({
type: 'task-month-fetched',
sourceId: creds.id,
month: monthStr,
transactionCount: txnCount,
});
results.push({ month: monthStr, data: validated });
} catch (err) {
emit({
type: 'task-month-error',
sourceId: creds.id,
month: monthStr,
error: err instanceof Error ? err.message : String(err),
});
}
}
if (results.length === 0) throw new Error('All months failed to scrape');
return results;
} finally {
await close();
}
}
export { countIsracardTransactions };