cashgrab 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/package.json +20 -0
- package/src/bankwest-balances.js +111 -0
- package/src/bankwest-transactions.js +203 -0
- package/src/browser-start.js +83 -0
- package/src/cli.js +114 -0
- package/src/stgeorge-balances.js +139 -0
- package/src/stgeorge-transactions.js +321 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# cashgrab
|
|
2
|
+
|
|
3
|
+
`cashgrab` is a local CLI that scrapes banking websites via Chrome DevTools Protocol (CDP). It launches a dedicated Chrome instance, connects over remote debugging, and automates authenticated sessions to pull account data.
|
|
4
|
+
|
|
5
|
+
Supported banks:
|
|
6
|
+
|
|
7
|
+
- **Bankwest** -- account balances and transaction export (QIF)
|
|
8
|
+
- **St.George** -- account balances and transaction export (CSV)
|
|
9
|
+
|
|
10
|
+
The repo also includes shell scripts for cleaning exported transaction files before import.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
npm link
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Browser
|
|
20
|
+
|
|
21
|
+
All scraping commands require Chrome running with remote debugging on port `9222`. Use `--profile` to copy your existing Chrome profile (cookies, logins) into the dedicated instance first.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cashgrab browser
|
|
25
|
+
cashgrab browser --profile
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The browser launches with its own profile directory (`~/.cache/browser-tools`). If Chrome is already running on `:9222`, the command exits immediately.
|
|
29
|
+
|
|
30
|
+
## Bankwest
|
|
31
|
+
|
|
32
|
+
Requires a logged-in Bankwest session.
|
|
33
|
+
|
|
34
|
+
### Account Balances
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cashgrab bankwest balances
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Transaction Export
|
|
41
|
+
|
|
42
|
+
Exports transactions as a QIF file. The account name is a case-insensitive substring match against the Bankwest dropdown -- it must match exactly one account, otherwise available options are listed.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cashgrab bankwest transactions "offset joint" -r L30Days
|
|
46
|
+
cashgrab bankwest transactions "home loan john" -r L90Days -o ~/Downloads
|
|
47
|
+
cashgrab bankwest transactions "offset joint" --from 01/01/2026 --to 28/03/2026
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## St.George
|
|
51
|
+
|
|
52
|
+
Requires a logged-in St.George session.
|
|
53
|
+
|
|
54
|
+
### Account Balances
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cashgrab st-george balances
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Transaction Export
|
|
61
|
+
|
|
62
|
+
Exports transactions as a CSV file. The account name is a case-insensitive substring match against the portfolio accounts.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cashgrab st-george transactions "000 111 222" -r L7Days
|
|
66
|
+
cashgrab st-george transactions "residential loan s000 111 222 333" -r L30Days -o ~/Downloads
|
|
67
|
+
cashgrab st-george transactions "complete freedom offset 000 111 222" --from 01/03/2026 --to 29/03/2026
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Cleaning
|
|
71
|
+
|
|
72
|
+
- [St George](src/stg) -- CSV cleaning
|
|
73
|
+
- [NAB](src/nab) -- QIF cleaning
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cashgrab",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"cashgrab": "src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"src/*.js",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/tekumara/cashgrab.git"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"commander": "^14.0.3",
|
|
18
|
+
"puppeteer-core": "^23.11.1"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bankwest Account Balances
|
|
3
|
+
*
|
|
4
|
+
* Connects to a running Chrome instance (CDP on localhost:9222),
|
|
5
|
+
* navigates to the Bankwest balances page,
|
|
6
|
+
* then prints all accounts and their balances.
|
|
7
|
+
*
|
|
8
|
+
* Prerequisites:
|
|
9
|
+
* - Chrome running with --remote-debugging-port=9222
|
|
10
|
+
* - Logged in to Bankwest Online Banking
|
|
11
|
+
*/
|
|
12
|
+
import puppeteer from "puppeteer-core";
|
|
13
|
+
|
|
14
|
+
const BALANCES_URL =
|
|
15
|
+
"https://online.bankwest.com.au/CMWeb/AccountInformation/AI/Balances.aspx";
|
|
16
|
+
const EXPECTED_URL_PATTERN = /online\.bankwest\.com\.au\/CMWeb\/AccountInformation\/AI\/Balances\.aspx/;
|
|
17
|
+
const CHROME_DEBUG_URL = "http://localhost:9222";
|
|
18
|
+
|
|
19
|
+
export async function bankwestBalances() {
|
|
20
|
+
const browser = await Promise.race([
|
|
21
|
+
puppeteer.connect({
|
|
22
|
+
browserURL: CHROME_DEBUG_URL,
|
|
23
|
+
defaultViewport: null,
|
|
24
|
+
}),
|
|
25
|
+
new Promise((_, reject) =>
|
|
26
|
+
setTimeout(() => reject(new Error("Connection timeout after 5s")), 5000)
|
|
27
|
+
),
|
|
28
|
+
]).catch((e) => {
|
|
29
|
+
console.error("✗ Could not connect to Chrome:", e.message);
|
|
30
|
+
console.error(" Make sure Chrome is running. Try: cashgrab browser");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const page = (await browser.pages()).at(-1) ?? await browser.newPage();
|
|
35
|
+
|
|
36
|
+
// Navigate to the balances page and let Bankwest redirect if the session is not logged in.
|
|
37
|
+
await page.goto(BALANCES_URL, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
38
|
+
|
|
39
|
+
const currentUrl = page.url();
|
|
40
|
+
if (!EXPECTED_URL_PATTERN.test(currentUrl)) {
|
|
41
|
+
console.error("✗ Not logged in. Expected Bankwest Account Balances page.");
|
|
42
|
+
console.error(` Current URL: ${currentUrl}`);
|
|
43
|
+
console.error(` Expected: ${BALANCES_URL}`);
|
|
44
|
+
console.error(" Log in to Bankwest Online Banking first.");
|
|
45
|
+
await browser.disconnect();
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Extract Bankwest's page state from the balances view.
|
|
50
|
+
const data = await page.evaluate(() => {
|
|
51
|
+
if (typeof ContainerContext === "undefined" || !ContainerContext?.accountBalancesContext) {
|
|
52
|
+
throw new Error("ContainerContext not found - page may not have loaded correctly");
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
customerName: ContainerContext.customerName,
|
|
56
|
+
asAt: ContainerContext.accountBalancesContext.AsAtDateTime,
|
|
57
|
+
balances: ContainerContext.accountBalancesContext.Balances,
|
|
58
|
+
netBalances: ContainerContext.accountBalancesContext.NetBalances,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await browser.disconnect();
|
|
63
|
+
|
|
64
|
+
const fmt = (n) =>
|
|
65
|
+
new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(n);
|
|
66
|
+
|
|
67
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
68
|
+
|
|
69
|
+
console.log(`\nBankwest Account Balances - ${data.customerName}`);
|
|
70
|
+
console.log(`As at: ${data.asAt}\n`);
|
|
71
|
+
|
|
72
|
+
// Group accounts by Bankwest category before printing.
|
|
73
|
+
const groups = {};
|
|
74
|
+
for (const acct of data.balances) {
|
|
75
|
+
const cat = acct.AccountCategoryCode ?? "OTHER";
|
|
76
|
+
(groups[cat] ??= []).push(acct);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const categoryLabels = {
|
|
80
|
+
TRANSACCTS: "Transaction Accounts",
|
|
81
|
+
MORTGAGE: "Home Loans",
|
|
82
|
+
CREDITCARDS: "Credit Cards",
|
|
83
|
+
OTHER: "Other",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (const [cat, accounts] of Object.entries(groups)) {
|
|
87
|
+
console.log(`${categoryLabels[cat] ?? cat}`);
|
|
88
|
+
console.log("─".repeat(72));
|
|
89
|
+
console.log(
|
|
90
|
+
`${pad("Nickname", 26)} ${pad("Account Number", 18)} ${pad("Current Balance", 18)} Available Balance`
|
|
91
|
+
);
|
|
92
|
+
console.log("─".repeat(72));
|
|
93
|
+
|
|
94
|
+
for (const account of accounts) {
|
|
95
|
+
const nickname = account.AccountNickName || account.AccountName;
|
|
96
|
+
console.log(
|
|
97
|
+
`${pad(nickname, 26)} ${pad(account.AccountNumber, 18)} ${pad(fmt(account.AccountCurrentBalance), 18)} ${fmt(account.AccountAvailableBalance)}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
console.log();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Print the overall net balance summary shown by Bankwest.
|
|
104
|
+
console.log("─".repeat(72));
|
|
105
|
+
console.log("Summary");
|
|
106
|
+
console.log("─".repeat(72));
|
|
107
|
+
for (const net of data.netBalances) {
|
|
108
|
+
console.log(`${pad(net.title, 20)} ${fmt(net.value)}`);
|
|
109
|
+
}
|
|
110
|
+
console.log();
|
|
111
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bankwest Transaction Export
|
|
3
|
+
*
|
|
4
|
+
* Connects to a running Chrome instance (CDP on localhost:9222),
|
|
5
|
+
* navigates to Bankwest transaction search, runs a search for the
|
|
6
|
+
* specified account and date range, then exports as MS Money (.qif).
|
|
7
|
+
*
|
|
8
|
+
* Prerequisites:
|
|
9
|
+
* - Chrome running with --remote-debugging-port=9222
|
|
10
|
+
* - Logged in to Bankwest Online Banking
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import puppeteer from "puppeteer-core";
|
|
14
|
+
import { mkdtemp, readdir, rename } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const SEARCH_URL =
|
|
19
|
+
"https://online.bankwest.com.au/CMWeb/AccountInformation/TS/TransactionSearch.aspx";
|
|
20
|
+
const SEARCH_URL_PATTERN =
|
|
21
|
+
/online\.bankwest\.com\.au\/CMWeb\/AccountInformation\/TS\/TransactionSearch\.aspx/;
|
|
22
|
+
const CHROME_DEBUG_URL = "http://localhost:9222";
|
|
23
|
+
|
|
24
|
+
// Normalize CLI-provided options and enforce valid date-range combinations.
|
|
25
|
+
export function normalizeTransactionOptions({
|
|
26
|
+
accountQuery,
|
|
27
|
+
range = "L30Days",
|
|
28
|
+
from = null,
|
|
29
|
+
to = null,
|
|
30
|
+
outputDir = process.cwd(),
|
|
31
|
+
} = {}) {
|
|
32
|
+
const opts = { accountQuery, range, from, to, outputDir: outputDir ?? process.cwd() };
|
|
33
|
+
|
|
34
|
+
if (!opts.accountQuery) {
|
|
35
|
+
console.error("✗ Account name is required");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (opts.to === "today") {
|
|
40
|
+
const d = new Date();
|
|
41
|
+
opts.to = `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ((opts.from && !opts.to) || (opts.to && !opts.from)) {
|
|
45
|
+
console.error("✗ Both --from and --to are required for custom date range");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (opts.from && opts.range !== "L30Days") {
|
|
50
|
+
console.error("✗ Cannot use --range with --from/--to");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (opts.from) opts.range = "CUSTOM";
|
|
55
|
+
|
|
56
|
+
return opts;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function bankwestTransactions(options) {
|
|
60
|
+
const opts = normalizeTransactionOptions(options);
|
|
61
|
+
|
|
62
|
+
const browser = await Promise.race([
|
|
63
|
+
puppeteer.connect({
|
|
64
|
+
browserURL: CHROME_DEBUG_URL,
|
|
65
|
+
defaultViewport: null,
|
|
66
|
+
}),
|
|
67
|
+
new Promise((_, reject) =>
|
|
68
|
+
setTimeout(() => reject(new Error("Connection timeout after 5s")), 5000)
|
|
69
|
+
),
|
|
70
|
+
]).catch((e) => {
|
|
71
|
+
console.error("✗ Could not connect to Chrome:", e.message);
|
|
72
|
+
console.error(" Make sure Chrome is running. Try: cashgrab browser");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const page = (await browser.pages()).at(-1);
|
|
77
|
+
|
|
78
|
+
if (!page) {
|
|
79
|
+
console.error("✗ No active tab found");
|
|
80
|
+
await browser.disconnect();
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Navigate to transaction search page
|
|
85
|
+
await page.goto(SEARCH_URL, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
86
|
+
|
|
87
|
+
const currentUrl = page.url();
|
|
88
|
+
if (!SEARCH_URL_PATTERN.test(currentUrl)) {
|
|
89
|
+
console.error("✗ Not logged in. Expected transaction search page.");
|
|
90
|
+
console.error(` Current URL: ${currentUrl}`);
|
|
91
|
+
console.error(" Log in to Bankwest Online Banking first.");
|
|
92
|
+
await browser.disconnect();
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Match the requested account against the Bankwest dropdown contents.
|
|
97
|
+
const account = await page.evaluate((query) => {
|
|
98
|
+
const select = document.getElementById("_ctl0_ContentMain_ddlAccount");
|
|
99
|
+
const options = Array.from(select.options).filter((o) => o.value !== "[All]");
|
|
100
|
+
const q = query.toLowerCase();
|
|
101
|
+
const matches = options.filter((o) => o.text.toLowerCase().includes(q));
|
|
102
|
+
|
|
103
|
+
if (matches.length === 0) {
|
|
104
|
+
return {
|
|
105
|
+
error: `No account matching "${query}"`,
|
|
106
|
+
available: options.map((o) => o.text),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (matches.length > 1) {
|
|
110
|
+
return {
|
|
111
|
+
error: `Ambiguous match for "${query}"`,
|
|
112
|
+
available: matches.map((o) => o.text),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { value: matches[0].value, text: matches[0].text };
|
|
117
|
+
}, opts.accountQuery);
|
|
118
|
+
|
|
119
|
+
if (account.error) {
|
|
120
|
+
console.error(`✗ ${account.error}`);
|
|
121
|
+
console.error(" Available accounts:");
|
|
122
|
+
for (const availableAccount of account.available) {
|
|
123
|
+
console.error(` ${availableAccount}`);
|
|
124
|
+
}
|
|
125
|
+
await browser.disconnect();
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const accountName = account.text.replace(/ - .*/, "");
|
|
130
|
+
console.error(`Account: ${account.text}`);
|
|
131
|
+
|
|
132
|
+
// Fill the search form and select MS Money export format.
|
|
133
|
+
await page.evaluate(
|
|
134
|
+
({ accountValue, range, from, to }) => {
|
|
135
|
+
document.getElementById("_ctl0_ContentMain_ddlAccount").value =
|
|
136
|
+
accountValue;
|
|
137
|
+
const rangeSelect = document.getElementById(
|
|
138
|
+
"_ctl0_ContentMain_ddlRangeOptions"
|
|
139
|
+
);
|
|
140
|
+
rangeSelect.value = range;
|
|
141
|
+
|
|
142
|
+
if (range === "CUSTOM") {
|
|
143
|
+
rangeSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
|
144
|
+
document.getElementById(
|
|
145
|
+
"_ctl0_ContentMain_dpFromDate_txtDate"
|
|
146
|
+
).value = from;
|
|
147
|
+
document.getElementById("_ctl0_ContentMain_dpToDate_txtDate").value =
|
|
148
|
+
to;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// QIF export is exposed as "MSMoney" in Bankwest's UI.
|
|
152
|
+
document.getElementById("_ctl0_ContentButtonsLeft_ddlExportType").value =
|
|
153
|
+
"MSMoney";
|
|
154
|
+
},
|
|
155
|
+
{ accountValue: account.value, range: opts.range, from: opts.from, to: opts.to }
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
console.error(`Range: ${opts.range}${opts.from ? ` (${opts.from} - ${opts.to})` : ""}`);
|
|
159
|
+
|
|
160
|
+
// ── Export ──────────────────────────────────────────────────────────────────
|
|
161
|
+
const downloadDir = await mkdtemp(join(tmpdir(), "bankwest-"));
|
|
162
|
+
|
|
163
|
+
const cdp = await page.createCDPSession();
|
|
164
|
+
await cdp.send("Page.setDownloadBehavior", {
|
|
165
|
+
behavior: "allow",
|
|
166
|
+
downloadPath: downloadDir,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// The export button triggers an ASP.NET postback that returns the download.
|
|
170
|
+
await cdp.send("Runtime.evaluate", {
|
|
171
|
+
expression:
|
|
172
|
+
'setTimeout(() => document.getElementById("_ctl0_ContentButtonsLeft_btnExport").click(), 50)',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Poll for the downloaded QIF file to appear in the temp directory.
|
|
176
|
+
let downloadedFile = null;
|
|
177
|
+
for (let i = 0; i < 30; i++) {
|
|
178
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
179
|
+
const files = await readdir(downloadDir);
|
|
180
|
+
const qif = files.find((f) => f.endsWith(".qif"));
|
|
181
|
+
if (qif) {
|
|
182
|
+
downloadedFile = join(downloadDir, qif);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!downloadedFile) {
|
|
188
|
+
console.error("✗ Export timed out - no .qif file received");
|
|
189
|
+
await browser.disconnect();
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Rename the downloaded file to include the matched account label.
|
|
194
|
+
const baseName = downloadedFile.split("/").pop().replace(".qif", "");
|
|
195
|
+
const suffix = accountName.replace(/\s+/g, "_");
|
|
196
|
+
const outputFile = join(opts.outputDir, `${baseName}_${suffix}.qif`);
|
|
197
|
+
|
|
198
|
+
await rename(downloadedFile, outputFile);
|
|
199
|
+
|
|
200
|
+
console.error(`✓ Exported: ${outputFile.split("/").pop()}`);
|
|
201
|
+
|
|
202
|
+
await browser.disconnect();
|
|
203
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawn, execSync } from "node:child_process";
|
|
2
|
+
import puppeteer from "puppeteer-core";
|
|
3
|
+
|
|
4
|
+
const SCRAPING_DIR = `${process.env.HOME}/.cache/browser-tools`;
|
|
5
|
+
const CHROME_DEBUG_URL = "http://localhost:9222";
|
|
6
|
+
const CHROME_EXECUTABLE =
|
|
7
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
8
|
+
|
|
9
|
+
export async function startBrowser({ profile = false } = {}) {
|
|
10
|
+
// Check if already running on :9222
|
|
11
|
+
try {
|
|
12
|
+
const browser = await puppeteer.connect({
|
|
13
|
+
browserURL: CHROME_DEBUG_URL,
|
|
14
|
+
defaultViewport: null,
|
|
15
|
+
});
|
|
16
|
+
await browser.disconnect();
|
|
17
|
+
console.log("✓ Chrome already running on :9222");
|
|
18
|
+
return;
|
|
19
|
+
} catch {}
|
|
20
|
+
|
|
21
|
+
// Setup profile directory
|
|
22
|
+
execSync(`mkdir -p "${SCRAPING_DIR}"`, { stdio: "ignore" });
|
|
23
|
+
|
|
24
|
+
// Remove SingletonLock to allow new instance
|
|
25
|
+
try {
|
|
26
|
+
execSync(
|
|
27
|
+
`rm -f "${SCRAPING_DIR}/SingletonLock" "${SCRAPING_DIR}/SingletonSocket" "${SCRAPING_DIR}/SingletonCookie"`,
|
|
28
|
+
{ stdio: "ignore" },
|
|
29
|
+
);
|
|
30
|
+
} catch {}
|
|
31
|
+
|
|
32
|
+
if (profile) {
|
|
33
|
+
console.log("Syncing profile...");
|
|
34
|
+
execSync(
|
|
35
|
+
`rsync -a --delete \
|
|
36
|
+
--exclude='SingletonLock' \
|
|
37
|
+
--exclude='SingletonSocket' \
|
|
38
|
+
--exclude='SingletonCookie' \
|
|
39
|
+
--exclude='*/Sessions/*' \
|
|
40
|
+
--exclude='*/Current Session' \
|
|
41
|
+
--exclude='*/Current Tabs' \
|
|
42
|
+
--exclude='*/Last Session' \
|
|
43
|
+
--exclude='*/Last Tabs' \
|
|
44
|
+
"${process.env.HOME}/Library/Application Support/Google/Chrome/" "${SCRAPING_DIR}/"`,
|
|
45
|
+
{ stdio: "pipe" },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Start Chrome with flags to force new instance
|
|
50
|
+
spawn(
|
|
51
|
+
CHROME_EXECUTABLE,
|
|
52
|
+
[
|
|
53
|
+
"--remote-debugging-port=9222",
|
|
54
|
+
`--user-data-dir=${SCRAPING_DIR}`,
|
|
55
|
+
"--no-first-run",
|
|
56
|
+
"--no-default-browser-check",
|
|
57
|
+
],
|
|
58
|
+
{ detached: true, stdio: "ignore" },
|
|
59
|
+
).unref();
|
|
60
|
+
|
|
61
|
+
// Wait for Chrome to be ready
|
|
62
|
+
let connected = false;
|
|
63
|
+
for (let i = 0; i < 30; i++) {
|
|
64
|
+
try {
|
|
65
|
+
const browser = await puppeteer.connect({
|
|
66
|
+
browserURL: CHROME_DEBUG_URL,
|
|
67
|
+
defaultViewport: null,
|
|
68
|
+
});
|
|
69
|
+
await browser.disconnect();
|
|
70
|
+
connected = true;
|
|
71
|
+
break;
|
|
72
|
+
} catch {
|
|
73
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!connected) {
|
|
78
|
+
console.error("✗ Failed to connect to Chrome");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`✓ Chrome started on :9222${profile ? " with your profile" : ""}`);
|
|
83
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { startBrowser } from "./browser-start.js";
|
|
5
|
+
import { bankwestBalances } from "./bankwest-balances.js";
|
|
6
|
+
import {
|
|
7
|
+
bankwestTransactions,
|
|
8
|
+
normalizeTransactionOptions,
|
|
9
|
+
} from "./bankwest-transactions.js";
|
|
10
|
+
import { stGeorgeBalances } from "./stgeorge-balances.js";
|
|
11
|
+
import {
|
|
12
|
+
normalizeStGeorgeTransactionOptions,
|
|
13
|
+
stGeorgeTransactions,
|
|
14
|
+
} from "./stgeorge-transactions.js";
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name("cashgrab")
|
|
20
|
+
.description("Browser automation helpers for cashgrab")
|
|
21
|
+
.showHelpAfterError("(add --help for usage details)");
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command("browser")
|
|
25
|
+
.description("Start Chrome with remote debugging on :9222")
|
|
26
|
+
.option("--profile", "Copy your default Chrome profile (cookies, logins)")
|
|
27
|
+
.action(async (options) => {
|
|
28
|
+
await startBrowser({ profile: options.profile });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const bankwest = program
|
|
32
|
+
.command("bankwest")
|
|
33
|
+
.description("Bankwest scraping commands");
|
|
34
|
+
|
|
35
|
+
bankwest
|
|
36
|
+
.command("balances")
|
|
37
|
+
.description("Print balances from the active Bankwest balances tab")
|
|
38
|
+
.action(async () => {
|
|
39
|
+
await bankwestBalances();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
bankwest
|
|
43
|
+
.command("transactions")
|
|
44
|
+
.description("Export transactions as a QIF file")
|
|
45
|
+
.argument(
|
|
46
|
+
"<accountName...>",
|
|
47
|
+
"Case-insensitive substring match against the Bankwest account dropdown",
|
|
48
|
+
)
|
|
49
|
+
.option(
|
|
50
|
+
"-r, --range <preset>",
|
|
51
|
+
"Date range preset: L7Days, L14Days, L30Days, L60Days, L90Days, LMONTH, SLMONTH, TLMONTH",
|
|
52
|
+
"L30Days",
|
|
53
|
+
)
|
|
54
|
+
.option("--from <date>", "Custom start date (DD/MM/YYYY), requires --to")
|
|
55
|
+
.option(
|
|
56
|
+
"--to <date>",
|
|
57
|
+
'Custom end date (DD/MM/YYYY or "today"), requires --from',
|
|
58
|
+
)
|
|
59
|
+
.option("-o, --output <dir>", "Output directory for the exported file")
|
|
60
|
+
.action(async (accountName, options) => {
|
|
61
|
+
await bankwestTransactions(
|
|
62
|
+
normalizeTransactionOptions({
|
|
63
|
+
accountQuery: accountName.join(" "),
|
|
64
|
+
range: options.range,
|
|
65
|
+
from: options.from,
|
|
66
|
+
to: options.to,
|
|
67
|
+
outputDir: options.output,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const stGeorge = program
|
|
73
|
+
.command("st-george")
|
|
74
|
+
.alias("stgeorge")
|
|
75
|
+
.description("St.George scraping commands");
|
|
76
|
+
|
|
77
|
+
stGeorge
|
|
78
|
+
.command("balances")
|
|
79
|
+
.description("Print balances from the St.George account portfolio page")
|
|
80
|
+
.action(async () => {
|
|
81
|
+
await stGeorgeBalances();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
stGeorge
|
|
85
|
+
.command("transactions")
|
|
86
|
+
.description("Export transactions as a CSV file")
|
|
87
|
+
.argument(
|
|
88
|
+
"<accountName...>",
|
|
89
|
+
"Case-insensitive substring match against the St.George portfolio accounts",
|
|
90
|
+
)
|
|
91
|
+
.option(
|
|
92
|
+
"-r, --range <preset>",
|
|
93
|
+
"Date range preset: L7Days, L30Days",
|
|
94
|
+
"L30Days",
|
|
95
|
+
)
|
|
96
|
+
.option("--from <date>", "Custom start date (DD/MM/YYYY), requires --to")
|
|
97
|
+
.option(
|
|
98
|
+
"--to <date>",
|
|
99
|
+
'Custom end date (DD/MM/YYYY or "today"), requires --from',
|
|
100
|
+
)
|
|
101
|
+
.option("-o, --output <dir>", "Output directory for the exported file")
|
|
102
|
+
.action(async (accountName, options) => {
|
|
103
|
+
await stGeorgeTransactions(
|
|
104
|
+
normalizeStGeorgeTransactionOptions({
|
|
105
|
+
accountQuery: accountName.join(" "),
|
|
106
|
+
range: options.range,
|
|
107
|
+
from: options.from,
|
|
108
|
+
to: options.to,
|
|
109
|
+
outputDir: options.output,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* St.George Account Balances
|
|
3
|
+
*
|
|
4
|
+
* Connects to a running Chrome instance (CDP on localhost:9222),
|
|
5
|
+
* navigates to the St.George account portfolio page,
|
|
6
|
+
* then prints all visible accounts and their balances.
|
|
7
|
+
*
|
|
8
|
+
* Prerequisites:
|
|
9
|
+
* - Chrome running with --remote-debugging-port=9222
|
|
10
|
+
* - Logged in to St.George Internet Banking
|
|
11
|
+
*/
|
|
12
|
+
import puppeteer from "puppeteer-core";
|
|
13
|
+
|
|
14
|
+
const BALANCES_URL =
|
|
15
|
+
"https://ibanking.stgeorge.com.au/ibank/viewAccountPortfolio.html";
|
|
16
|
+
const CHROME_DEBUG_URL = "http://localhost:9222";
|
|
17
|
+
|
|
18
|
+
function parseCurrency(value) {
|
|
19
|
+
if (typeof value !== "string") return null;
|
|
20
|
+
const normalized = value.replace(/[^0-9.-]/g, "");
|
|
21
|
+
if (!normalized || normalized === "-" || normalized === ".") return null;
|
|
22
|
+
|
|
23
|
+
const parsed = Number(normalized);
|
|
24
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatCurrency(value) {
|
|
28
|
+
if (typeof value !== "number" || Number.isNaN(value)) return "-";
|
|
29
|
+
|
|
30
|
+
return new Intl.NumberFormat("en-AU", {
|
|
31
|
+
style: "currency",
|
|
32
|
+
currency: "AUD",
|
|
33
|
+
}).format(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function stGeorgeBalances() {
|
|
37
|
+
const browser = await Promise.race([
|
|
38
|
+
puppeteer.connect({
|
|
39
|
+
browserURL: CHROME_DEBUG_URL,
|
|
40
|
+
defaultViewport: null,
|
|
41
|
+
}),
|
|
42
|
+
new Promise((_, reject) =>
|
|
43
|
+
setTimeout(() => reject(new Error("Connection timeout after 5s")), 5000)
|
|
44
|
+
),
|
|
45
|
+
]).catch((error) => {
|
|
46
|
+
console.error("✗ Could not connect to Chrome:", error.message);
|
|
47
|
+
console.error(" Make sure Chrome is running. Try: cashgrab browser");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const page = (await browser.pages()).at(-1) ?? (await browser.newPage());
|
|
52
|
+
|
|
53
|
+
await page.goto(BALANCES_URL, {
|
|
54
|
+
waitUntil: "domcontentloaded",
|
|
55
|
+
timeout: 15000,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await page
|
|
59
|
+
.waitForFunction(() => document.readyState === "complete", {
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {});
|
|
63
|
+
|
|
64
|
+
const data = await page.evaluate(() => {
|
|
65
|
+
const normalizeText = (value) => value?.replace(/\s+/g, " ").trim() ?? "";
|
|
66
|
+
|
|
67
|
+
const accounts = Array.from(
|
|
68
|
+
document.querySelectorAll("#acctSummaryList > li")
|
|
69
|
+
).map((item) => {
|
|
70
|
+
const currentBalanceText =
|
|
71
|
+
item.querySelector("dl.balance-details dd")?.textContent ?? "";
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: normalizeText(
|
|
75
|
+
item.querySelector("h2 a")?.textContent ?? item.dataset.acctalias ?? ""
|
|
76
|
+
),
|
|
77
|
+
bsb: normalizeText(
|
|
78
|
+
item.querySelector("dt.bsb-number + dd")?.textContent ?? ""
|
|
79
|
+
),
|
|
80
|
+
accountNumber: normalizeText(
|
|
81
|
+
item.querySelector("dt.account-number + dd")?.textContent ?? ""
|
|
82
|
+
),
|
|
83
|
+
currentBalance:
|
|
84
|
+
typeof item.dataset.currbal === "string"
|
|
85
|
+
? item.dataset.currbal
|
|
86
|
+
: normalizeText(currentBalanceText),
|
|
87
|
+
currentBalanceText: normalizeText(currentBalanceText),
|
|
88
|
+
availableBalance: normalizeText(
|
|
89
|
+
item.querySelector("dt.available-balance + dd")?.textContent ?? ""
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
currentUrl: location.href,
|
|
96
|
+
bodyText: normalizeText(document.body.innerText).slice(0, 500),
|
|
97
|
+
accounts,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await browser.disconnect();
|
|
102
|
+
|
|
103
|
+
if (data.accounts.length === 0) {
|
|
104
|
+
console.error(
|
|
105
|
+
"✗ Not logged in. Expected St.George account cards on the portfolio page."
|
|
106
|
+
);
|
|
107
|
+
console.error(` Current URL: ${data.currentUrl}`);
|
|
108
|
+
console.error(` Expected: ${BALANCES_URL}`);
|
|
109
|
+
if (data.bodyText) {
|
|
110
|
+
console.error(` Page says: ${data.bodyText}`);
|
|
111
|
+
}
|
|
112
|
+
console.error(" Log in to St.George Internet Banking first.");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const accounts = data.accounts.map((account) => ({
|
|
117
|
+
...account,
|
|
118
|
+
currentBalanceValue:
|
|
119
|
+
parseCurrency(account.currentBalance) ??
|
|
120
|
+
parseCurrency(account.currentBalanceText),
|
|
121
|
+
availableBalanceValue: parseCurrency(account.availableBalance),
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
const pad = (value, width) => String(value ?? "-").padEnd(width);
|
|
125
|
+
|
|
126
|
+
console.log("\nSt.George Account Balances\n");
|
|
127
|
+
console.log(
|
|
128
|
+
`${pad("Nickname", 28)} ${pad("BSB", 10)} ${pad("Account Number", 18)} ${pad("Current Balance", 18)} Available Balance`
|
|
129
|
+
);
|
|
130
|
+
console.log("─".repeat(94));
|
|
131
|
+
|
|
132
|
+
for (const account of accounts) {
|
|
133
|
+
console.log(
|
|
134
|
+
`${pad(account.name || "-", 28)} ${pad(account.bsb || "-", 10)} ${pad(account.accountNumber || "-", 18)} ${pad(formatCurrency(account.currentBalanceValue), 18)} ${formatCurrency(account.availableBalanceValue)}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log();
|
|
139
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* St.George Transaction Export
|
|
3
|
+
*
|
|
4
|
+
* Connects to a running Chrome instance (CDP on localhost:9222),
|
|
5
|
+
* navigates from the St.George account portfolio page to the matched
|
|
6
|
+
* account details page, then exports transactions as CSV.
|
|
7
|
+
*
|
|
8
|
+
* Prerequisites:
|
|
9
|
+
* - Chrome running with --remote-debugging-port=9222
|
|
10
|
+
* - Logged in to St.George Internet Banking
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import puppeteer from "puppeteer-core";
|
|
14
|
+
import { mkdtemp, readdir, rename } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const PORTFOLIO_URL =
|
|
19
|
+
"https://ibanking.stgeorge.com.au/ibank/viewAccountPortfolio.html";
|
|
20
|
+
const CHROME_DEBUG_URL = "http://localhost:9222";
|
|
21
|
+
|
|
22
|
+
const RANGE_TO_SELECTED_OPTION = {
|
|
23
|
+
L7Days: 0,
|
|
24
|
+
L30Days: 1,
|
|
25
|
+
CUSTOM: 2,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function slugify(value) {
|
|
29
|
+
return String(value)
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/\s+/g, "_")
|
|
32
|
+
.replace(/[^A-Za-z0-9_.-]/g, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatToday() {
|
|
36
|
+
const today = new Date();
|
|
37
|
+
return `${String(today.getDate()).padStart(2, "0")}/${String(
|
|
38
|
+
today.getMonth() + 1
|
|
39
|
+
).padStart(2, "0")}/${today.getFullYear()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeStGeorgeTransactionOptions({
|
|
43
|
+
accountQuery,
|
|
44
|
+
range = "L30Days",
|
|
45
|
+
from = null,
|
|
46
|
+
to = null,
|
|
47
|
+
outputDir = process.cwd(),
|
|
48
|
+
} = {}) {
|
|
49
|
+
const opts = {
|
|
50
|
+
accountQuery,
|
|
51
|
+
range,
|
|
52
|
+
from,
|
|
53
|
+
to,
|
|
54
|
+
outputDir: outputDir ?? process.cwd(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (!opts.accountQuery) {
|
|
58
|
+
console.error("✗ Account name is required");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (opts.to === "today") {
|
|
63
|
+
opts.to = formatToday();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ((opts.from && !opts.to) || (opts.to && !opts.from)) {
|
|
67
|
+
console.error("✗ Both --from and --to are required for custom date range");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (opts.from && opts.range !== "L30Days") {
|
|
72
|
+
console.error("✗ Cannot use --range with --from/--to");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (opts.from) {
|
|
77
|
+
opts.range = "CUSTOM";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!["L7Days", "L30Days", "CUSTOM"].includes(opts.range)) {
|
|
81
|
+
console.error(`✗ Unsupported St.George range "${opts.range}"`);
|
|
82
|
+
console.error(" Supported ranges: L7Days, L30Days, or --from/--to");
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return opts;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function stGeorgeTransactions(options) {
|
|
90
|
+
const opts = normalizeStGeorgeTransactionOptions(options);
|
|
91
|
+
|
|
92
|
+
const browser = await Promise.race([
|
|
93
|
+
puppeteer.connect({
|
|
94
|
+
browserURL: CHROME_DEBUG_URL,
|
|
95
|
+
defaultViewport: null,
|
|
96
|
+
}),
|
|
97
|
+
new Promise((_, reject) =>
|
|
98
|
+
setTimeout(() => reject(new Error("Connection timeout after 5s")), 5000)
|
|
99
|
+
),
|
|
100
|
+
]).catch((error) => {
|
|
101
|
+
console.error("✗ Could not connect to Chrome:", error.message);
|
|
102
|
+
console.error(" Make sure Chrome is running. Try: cashgrab browser");
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const page = (await browser.pages()).at(-1) ?? (await browser.newPage());
|
|
107
|
+
|
|
108
|
+
await page.goto(PORTFOLIO_URL, {
|
|
109
|
+
waitUntil: "domcontentloaded",
|
|
110
|
+
timeout: 15000,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await page
|
|
114
|
+
.waitForFunction(() => document.readyState === "complete", {
|
|
115
|
+
timeout: 5000,
|
|
116
|
+
})
|
|
117
|
+
.catch(() => {});
|
|
118
|
+
|
|
119
|
+
const account = await page.evaluate((query) => {
|
|
120
|
+
const normalizeText = (value) => value?.replace(/\s+/g, " ").trim() ?? "";
|
|
121
|
+
const items = Array.from(document.querySelectorAll("#acctSummaryList > li"));
|
|
122
|
+
const q = query.toLowerCase();
|
|
123
|
+
|
|
124
|
+
const accounts = items.map((item) => {
|
|
125
|
+
const name = normalizeText(
|
|
126
|
+
item.querySelector("h2 a")?.textContent ?? item.dataset.acctalias ?? ""
|
|
127
|
+
);
|
|
128
|
+
const bsb = normalizeText(
|
|
129
|
+
item.querySelector("dt.bsb-number + dd")?.textContent ?? ""
|
|
130
|
+
);
|
|
131
|
+
const accountNumber = normalizeText(
|
|
132
|
+
item.querySelector("dt.account-number + dd")?.textContent ?? ""
|
|
133
|
+
);
|
|
134
|
+
const href = item.querySelector("h2 a")?.getAttribute("href") ?? "";
|
|
135
|
+
const label = [name, accountNumber].filter(Boolean).join(" ");
|
|
136
|
+
const matchText = [name, accountNumber, bsb].join(" ").toLowerCase();
|
|
137
|
+
const indexMatch = href.match(/accountDetails\.action\?index=(\d+)/);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
index: indexMatch?.[1] ?? null,
|
|
141
|
+
name,
|
|
142
|
+
bsb,
|
|
143
|
+
accountNumber,
|
|
144
|
+
href,
|
|
145
|
+
label,
|
|
146
|
+
matchText,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (accounts.length === 0) {
|
|
151
|
+
return {
|
|
152
|
+
error: "Not logged in. Expected St.George account cards on the portfolio page.",
|
|
153
|
+
currentUrl: location.href,
|
|
154
|
+
available: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const matches = accounts.filter((account) => account.matchText.includes(q));
|
|
159
|
+
|
|
160
|
+
if (matches.length === 0) {
|
|
161
|
+
return {
|
|
162
|
+
error: `No account matching "${query}"`,
|
|
163
|
+
currentUrl: location.href,
|
|
164
|
+
available: accounts.map((account) => account.label),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (matches.length > 1) {
|
|
169
|
+
return {
|
|
170
|
+
error: `Ambiguous match for "${query}"`,
|
|
171
|
+
currentUrl: location.href,
|
|
172
|
+
available: matches.map((account) => account.label),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
currentUrl: location.href,
|
|
178
|
+
account: matches[0],
|
|
179
|
+
};
|
|
180
|
+
}, opts.accountQuery);
|
|
181
|
+
|
|
182
|
+
if (account.error) {
|
|
183
|
+
console.error(`✗ ${account.error}`);
|
|
184
|
+
if (account.currentUrl) {
|
|
185
|
+
console.error(` Current URL: ${account.currentUrl}`);
|
|
186
|
+
}
|
|
187
|
+
if (account.available.length > 0) {
|
|
188
|
+
console.error(" Available accounts:");
|
|
189
|
+
for (const availableAccount of account.available) {
|
|
190
|
+
console.error(` ${availableAccount}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await browser.disconnect();
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!account.account?.index) {
|
|
198
|
+
console.error("✗ Could not determine St.George account index");
|
|
199
|
+
await browser.disconnect();
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const accountUrl = new URL(
|
|
204
|
+
`accountDetails.action?index=${account.account.index}`,
|
|
205
|
+
PORTFOLIO_URL
|
|
206
|
+
).toString();
|
|
207
|
+
|
|
208
|
+
await page.goto(accountUrl, {
|
|
209
|
+
waitUntil: "domcontentloaded",
|
|
210
|
+
timeout: 15000,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await page
|
|
214
|
+
.waitForFunction(() => document.readyState === "complete", {
|
|
215
|
+
timeout: 5000,
|
|
216
|
+
})
|
|
217
|
+
.catch(() => {});
|
|
218
|
+
|
|
219
|
+
const accountDetails = await page.evaluate((expectedIndex) => {
|
|
220
|
+
const info = document.querySelector("div.account-info");
|
|
221
|
+
return {
|
|
222
|
+
currentUrl: location.href,
|
|
223
|
+
pageIndex: info?.id ?? null,
|
|
224
|
+
visibleAccount: info?.innerText?.replace(/\s+/g, " ").trim() ?? "",
|
|
225
|
+
hasExportControl: !!document.getElementById("transHistExport"),
|
|
226
|
+
bodyText: document.body.innerText.replace(/\s+/g, " ").trim().slice(0, 500),
|
|
227
|
+
expectedIndex,
|
|
228
|
+
};
|
|
229
|
+
}, account.account.index);
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
!accountDetails.hasExportControl ||
|
|
233
|
+
accountDetails.pageIndex !== String(accountDetails.expectedIndex)
|
|
234
|
+
) {
|
|
235
|
+
console.error("✗ Could not load the St.George account details export page.");
|
|
236
|
+
console.error(` Current URL: ${accountDetails.currentUrl}`);
|
|
237
|
+
if (accountDetails.bodyText) {
|
|
238
|
+
console.error(` Page says: ${accountDetails.bodyText}`);
|
|
239
|
+
}
|
|
240
|
+
await browser.disconnect();
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const selectedOption = RANGE_TO_SELECTED_OPTION[opts.range];
|
|
245
|
+
const downloadUrl = await page.evaluate(
|
|
246
|
+
({ index, selectedOption, from, to }) => {
|
|
247
|
+
const params = new URLSearchParams({
|
|
248
|
+
newPage: "1",
|
|
249
|
+
index: String(index),
|
|
250
|
+
exportFileFormat: "CSV",
|
|
251
|
+
exportDateFormat: "dd/MM/yyyy",
|
|
252
|
+
selectedOption: String(selectedOption),
|
|
253
|
+
dateFrom: from ?? "",
|
|
254
|
+
dateTo: to ?? "",
|
|
255
|
+
selectedAmountFrom: "",
|
|
256
|
+
selectedAmountTo: "",
|
|
257
|
+
selectedDrCrOption: "0",
|
|
258
|
+
includeCategories: "true",
|
|
259
|
+
includeSubCategories: "true",
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return new URL(`exportTransactions.action?${params.toString()}`, location.href).toString();
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
index: account.account.index,
|
|
266
|
+
selectedOption,
|
|
267
|
+
from: opts.from,
|
|
268
|
+
to: opts.to,
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
console.error(`Account: ${account.account.label}`);
|
|
273
|
+
console.error(
|
|
274
|
+
`Range: ${opts.range}${opts.from ? ` (${opts.from} - ${opts.to})` : ""}`
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const downloadDir = await mkdtemp(join(tmpdir(), "stgeorge-"));
|
|
278
|
+
|
|
279
|
+
const cdp = await page.createCDPSession();
|
|
280
|
+
await cdp.send("Page.setDownloadBehavior", {
|
|
281
|
+
behavior: "allow",
|
|
282
|
+
downloadPath: downloadDir,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await cdp.send("Runtime.evaluate", {
|
|
286
|
+
expression: `setTimeout(() => { window.location.href = ${JSON.stringify(
|
|
287
|
+
downloadUrl
|
|
288
|
+
)}; }, 50)`,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
let downloadedFile = null;
|
|
292
|
+
for (let i = 0; i < 40; i++) {
|
|
293
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
294
|
+
const files = await readdir(downloadDir);
|
|
295
|
+
const csv = files.find(
|
|
296
|
+
(file) => file.endsWith(".csv") && !file.endsWith(".crdownload")
|
|
297
|
+
);
|
|
298
|
+
if (csv) {
|
|
299
|
+
downloadedFile = join(downloadDir, csv);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!downloadedFile) {
|
|
305
|
+
console.error("✗ Export timed out - no .csv file received");
|
|
306
|
+
await browser.disconnect();
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const baseName = downloadedFile.split("/").pop().replace(/\.csv$/i, "");
|
|
311
|
+
const suffix = `${slugify(account.account.name)}_${slugify(
|
|
312
|
+
account.account.accountNumber
|
|
313
|
+
)}`;
|
|
314
|
+
const outputFile = join(opts.outputDir, `${baseName}_${suffix}.csv`);
|
|
315
|
+
|
|
316
|
+
await rename(downloadedFile, outputFile);
|
|
317
|
+
|
|
318
|
+
console.error(`✓ Exported: ${outputFile.split("/").pop()}`);
|
|
319
|
+
|
|
320
|
+
await browser.disconnect();
|
|
321
|
+
}
|