Last active
August 29, 2023 00:37
-
-
Save Jessidhia/fbf19804e1ce2949a70aa36561930da7 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
// ==UserScript== | |
// @name PayPay Bank CSV Export | |
// @match https://login.paypay-bank.co.jp/wctx/* | |
// @match https://login.japannetbank.co.jp/wctx/* | |
// @icon https://login.paypay-bank.co.jp/favicon.ico | |
// @grant GM.setValue | |
// @grant GM.getValue | |
// @noframes | |
// @updateURL https://gist.github.com/Jessidhia/fbf19804e1ce2949a70aa36561930da7/raw/jnb.user.js | |
// @version 2.3 | |
// @author Jessidhia | |
// ==/UserScript== | |
;(async function () { | |
'use strict' | |
/** | |
* @typedef {object} GMAPI | |
* @prop {<T = any>(key: string, defaultValue?: T) => Promise<T>} getValue | |
* @prop {(key: string, value: any) => Promise<void>} setValue | |
*/ | |
/** @type {GMAPI}*/ | |
const GM = globalThis.GM | |
/** | |
* @typedef {object} CCDetail | |
* @prop {string} payee | |
*/ | |
const pageTitle = document.querySelector('.titleL h1').textContent | |
if (pageTitle.endsWith('デビット ご利用明細一覧')) { | |
await GM.setValue('ccdetail', { | |
...(await GM.getValue('ccdetail', null)), | |
...parseDebitCardStatement(), | |
}) | |
} | |
if (pageTitle.endsWith('普通預金取引明細照会')) { | |
const buttonRow = document.querySelector('.blkTitleR') | |
if (buttonRow) { | |
const text = document.createElement('span') | |
text.classList.add('link-icon-csv') | |
text.textContent = 'YNAB CSV' | |
const button = document.createElement('a') | |
button.appendChild(text) | |
button.href = 'javascript:void 0' | |
button.addEventListener('click', handleExportClick) | |
const li = document.createElement('li') | |
li.appendChild(button) | |
buttonRow.appendChild(li) | |
} | |
} | |
return | |
/** @param {Event} e */ | |
async function handleExportClick(e) { | |
e.preventDefault() | |
const { parsed, missing } = parseTransactions( | |
await GM.getValue('ccdetail', {}) | |
) | |
if (missing.length > 0) { | |
alert( | |
`Missing Debit Card transaction details for the following transactions:\n\n${missing.join( | |
'\n' | |
)}\n\nVisit the appropriate Debit Card details page to load the missing details.` | |
) | |
return | |
} | |
exportToCsv('jnb.csv', [ | |
['Date', 'Payee', 'Memo', 'Outflow', 'Inflow'], | |
...parsed.map(({ date, payee, expense, income }) => [ | |
date, | |
payee, | |
'', | |
expense, | |
income, | |
]), | |
]) | |
} | |
/** @param {{[id: string]: CCDetail}} details */ | |
function parseTransactions(details) { | |
const tableRows = /** @type {HTMLElement[]} */ document.querySelectorAll( | |
'.detail-list-wrap .detail-inner > ul' | |
) | |
const parsed = [] | |
const missing = [] | |
for (const row of tableRows) { | |
if (row.childElementCount !== 4) { | |
alert('Table format changed') | |
return | |
} | |
const rawDate = row.children[0].children[0].textContent.trim() | |
const payee = Array.from(row.children[0].childNodes).filter(node => node.nodeType === 3 && node.textContent.trim() !== '')[0].textContent.trim() | |
const isExpense = new Set(row.children[1].classList).has('colRed') | |
const value = Array.from(row.children[1].childNodes).filter(node => node.nodeType === 3 && node.textContent.trim() !== '')[0].textContent.trim().replace(/,/g, '') | |
// YNAB does not read time, only date | |
const date = rawDate.replace(/\//g, '-').replace(/ .*$/, '') | |
const tag = /^.デビット.* ([A-Z0-9]+)$/.exec(payee)?.[1] | |
if (tag) { | |
const detail = details[tag] | |
if (!detail) { | |
missing.push(tag) | |
} else { | |
parsed.push({ date, payee: detail.payee, expense: isExpense ? value : '', income: !isExpense ? value : '' }) | |
} | |
} else { | |
parsed.push({ date, payee, expense: isExpense ? value : '', income: !isExpense ? value : '' }) | |
} | |
} | |
return { parsed, missing } | |
} | |
function parseDebitCardStatement() { | |
const entries = document.querySelectorAll('.detailTbl > ul') | |
/** @type {{[id: string]: CCDetail}} */ | |
const data = {} | |
for (const { children: [,dateAndPayeeEl, , idEl] } of entries) { | |
const id = idEl.childNodes[2]?.textContent | |
const payee = dateAndPayeeEl.querySelector('.fBold')?.textContent | |
if (id && payee) { | |
data[id] = { payee } | |
} | |
} | |
return data | |
} | |
// refactored from https://stackoverflow.com/a/24922761 | |
/** | |
* @typedef {null|string|number|Date} CSVData | |
* @param {string} filename | |
* @param {ReadonlyArray<ReadonlyArray<CSVData>>} rows | |
*/ | |
function exportToCsv(filename, rows) { | |
const csvFile = rows | |
.map(row => | |
row | |
.map(item => { | |
const itemString = | |
item === null | |
? '' | |
: item instanceof Date | |
? item.toLocaleString() | |
: item.toString() | |
const escaped = itemString.replace(/\"/g, '""') | |
return /(\"|,|\n)/.test(escaped) ? `\"${escaped}\"` : escaped | |
}) | |
.join(',') | |
) | |
.join('\n') | |
const blob = new Blob([csvFile], { type: 'text/csv;charset=utf-8;' }) | |
const link = document.createElement('a') | |
const url = URL.createObjectURL(blob) | |
link.setAttribute('href', url) | |
link.setAttribute('download', filename) | |
link.style.visibility = 'hidden' | |
document.body.appendChild(link) | |
link.click() | |
document.body.removeChild(link) | |
} | |
})() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment