diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml index 1e48f67..119ef27 100644 --- a/.github/workflows/build-extension.yml +++ b/.github/workflows/build-extension.yml @@ -2,43 +2,74 @@ name: Build Extension on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: +permissions: + contents: read + +concurrency: + group: build-extension-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - build: - runs-on: ubuntu-latest - + validate: + name: Test and validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - name: Check JavaScript syntax + run: | + node --check background.js + node --check content.js + node --check popup.js + + - name: Run tests + run: node --test + + package: + name: Package extension + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: - - uses: actions/checkout@v4 - - - name: Install dependencies - run: | - if command -v sudo >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y zip - else - apt-get update - apt-get install -y zip - fi - - - name: Create Extension ZIP - run: | - # Zip only necessary files for the extension - zip -r extension.zip manifest.json content.js background.js popup.html popup.css popup.js icon.png README.md - - - name: Verify ZIP creation - run: | - if [ ! -f extension.zip ]; then - echo "Failed to create extension.zip" - exit 1 - fi - echo "Extension packaged successfully ($(du -h extension.zip | cut -f1))" - - - name: Upload Extension Artifact - uses: actions/upload-artifact@v4 - with: - name: credit-karma-extractor - path: extension.zip + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Create extension archive + run: >- + zip -X extension.zip + manifest.json content.js background.js popup.html popup.css popup.js + icon.png README.md + + - name: Verify archive integrity + run: | + unzip -t extension.zip + sha256sum extension.zip > extension.zip.sha256 + + - name: Upload extension artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: credit-karma-extractor + path: | + extension.zip + extension.zip.sha256 + if-no-files-found: error + retention-days: 14 + compression-level: 0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..788000d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent instructions + +- Follow [CONTRIBUTING.md](CONTRIBUTING.md), including its testing and sensitive-data rules. +- Never commit HAR files, credentials, or real Credit Karma financial data. +- Run the documented checks before submitting changes. + +## AI transparency + +Pull requests generated or materially assisted by an LLM must include an **AI assistance** section that: + +- clearly states that AI was used and names the tool or model when known; +- distinguishes automated checks from human review or manual testing; and +- says explicitly when the changes have not been manually tested by a human. + +Do not present AI-generated work as solely human-authored. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f1a7d3d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +Thanks for helping improve Credit Karma Data Extractor. + +## Development workflow + +1. Fork the repository and create a focused branch. +2. Make your changes without adding captured financial data, credentials, or generated archives. +3. Run the local checks: + + ```sh + node --check background.js + node --check content.js + node --check popup.js + node --test + ``` + +4. Load the repository as an unpacked extension from `chrome://extensions` and test the affected export on Credit Karma. +5. Open a pull request that explains the problem, the change, and how you tested it. + +## Recording a HAR for API changes + +A HAR (HTTP Archive) can help identify changed GraphQL operations or response shapes. HAR files can also contain authentication material and private financial information, so handle them as sensitive files. + +1. Sign in to Credit Karma and open the page related to the issue. +2. Open Chrome DevTools, select **Network**, and make sure recording is enabled. +3. Clear the network log, then reload the page and reproduce only the behavior being investigated. +4. Optionally select **Fetch/XHR** to limit the captured requests. +5. Use **Export HAR (sanitized)**, or right-click the request list and select **Save all listed as HAR (sanitized)**. + +Chrome documents these steps in its [Network features reference](https://developer.chrome.com/docs/devtools/network/reference/#save-all-as-har). + +### Before sharing a HAR + +- Never commit a HAR file to this repository. +- Never attach an unreviewed HAR to a public issue or pull request. +- Use the sanitized export. Do not enable Chrome's option to export HAR files with sensitive data. +- Open the HAR in a text editor and check for cookies, authorization headers, tokens, email addresses, names, account identifiers, balances, transactions, and other personal data. +- Prefer sharing a minimal redacted request/response sample or schema instead of the full HAR. +- If a full HAR is genuinely required, ask a maintainer how to share it privately before sending it. + +If you accidentally publish a HAR containing sensitive information, remove it immediately and treat any exposed session or credentials as compromised. + +## Pull request guidelines + +- Keep changes focused and avoid unrelated formatting edits. +- Add or update tests when changing parsers or export formats. +- Do not include real Credit Karma data in tests; use small synthetic fixtures. +- Ensure CI passes before requesting review. diff --git a/README.md b/README.md index 81f4c91..6bea48f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Credit Karma Transaction Extractor Chrome Extension +# Credit Karma Data Extractor Chrome Extension [![Build Extension](https://github.com/cbangera2/CreditKarmaExtractor/actions/workflows/build-extension.yml/badge.svg)](https://github.com/cbangera2/CreditKarmaExtractor/actions/workflows/build-extension.yml) ![GitHub stars](https://img.shields.io/github/stars/cbangera2/CreditKarmaExtractor?style=social) @@ -6,9 +6,7 @@ ![GitHub top language](https://img.shields.io/github/languages/top/cbangera2/CreditKarmaExtractor) ![GitHub last commit](https://img.shields.io/github/last-commit/cbangera2/CreditKarmaExtractor?color=red) -The **Credit Karma Transaction Extractor** allows you to instantly export your entire transaction history from Credit Karma to CSV. - -**🎉 New in v2.0:** I've completely rewritten the engine. It now uses Credit Karma's internal API (GraphQL) to fetch thousands of transactions in seconds, capturing details like Account Names, Categories, and Merchant info that were previously inaccessible or required slow manual scraping. +The **Credit Karma Data Extractor** exports transaction history, net worth history, and investment values from Credit Karma to CSV. ## Screenshots image @@ -21,6 +19,7 @@ The **Credit Karma Transaction Extractor** allows you to instantly export your e - **Modern UI**: Beautiful interface with Dark Mode support and Quick Date presets (YTD, Last Year). - **Instant Stop**: Cancel huge extractions immediately without losing data—what you've fetched is saved. - **Smart Export**: Automatically generates `All Data`, `Income Only`, and `Expenses Only` files. +- **Wealth History**: Export the full Net Worth and Investment graph series as separate, date-filtered CSV files. ## Quick Start @@ -30,11 +29,12 @@ The **Credit Karma Transaction Extractor** allows you to instantly export your e - Enable **Developer mode** (top right toggle). - Click **Load unpacked** and select the extension directory. -2. **Export Transactions**: +2. **Export Data**: - Go to [Credit Karma Transactions](https://www.creditkarma.com/networth/transactions). - Click the extension icon. - Select your date range (or click "Last Year"). - - Click **Export Transactions**. + - Choose the transaction and/or wealth-history files to generate. + - Click **Export Selected Data**. - Watch the progress indicator and wait for your CSV files! ## Analyze Your Data @@ -47,6 +47,12 @@ Once you have your CSVs, you can use them with: ## Changelog +### Version 2.1 (July 2026) +- Added separate CSV exports for Net Worth and Investment graph values. +- Wealth exports use the complete graph dataset and respect the selected date range. +- Updated transaction exports to preserve user-corrected categories through the paginated `transactionsHub` API, with safer pagination and a fallback for incomplete responses—thanks to [@CarterDunn](https://github.com/CarterDunn) for the contribution. +- Added automatic content-script reinjection and a raw API JSON debug export for easier troubleshooting. + ### Version 2.0 (January 2026) - **Major Rewrite**: Switched to GraphQL API-based extraction. - **New UI**: Complete visual overhaul with Cards, Dark Mode, and compact layout. @@ -61,10 +67,7 @@ Once you have your CSVs, you can use them with: ## Contributing -Contributions are welcome! Feel free to: -1. Fork the repository. -2. Create a feature branch. -3. Submit a pull request. +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow, pull request guidelines, and instructions for safely recording a sanitized HAR when investigating Credit Karma API changes. ## Credits diff --git a/content.js b/content.js index b6a3e6d..6625e95 100644 --- a/content.js +++ b/content.js @@ -10,7 +10,9 @@ const CONFIG = { // GraphQL Operation Hashes (APQ) // These may need to be updated if Credit Karma updates their API schema TRANSACTIONS_LIST_HASH: 'c3c0a630b5cd938595c5901807f63b807e63c71f54a8fcb55e8c9084cb70832a', - TRANSACTIONS_QUERY_HASH: 'f669c7e42eb464861cb77d9f27826d0847ddfb5f5079a6ab7e5e2470c9617db8' + TRANSACTIONS_QUERY_HASH: 'f669c7e42eb464861cb77d9f27826d0847ddfb5f5079a6ab7e5e2470c9617db8', + NET_WORTH_HASH: '3b00cd72288d3dbd053f2d6d3310a2ab95f6927c7ea14843503c498e3c384702', + ACCOUNT_L2_HASH: 'ae3d3cc725b67ede7ec9216518daf4c06695c583301d6749881a1a55a9c061f2' }; // Cache for the access token @@ -139,6 +141,139 @@ function generateTraceId() { }); } +/** + * Run one of Credit Karma's persisted GraphQL queries, retrying once when the + * access token needs to be refreshed. + */ +async function fetchPersistedQuery(operationName, sha256Hash, variables, signal) { + const requestBody = { + extensions: { + persistedQuery: { + sha256Hash, + version: 1 + } + }, + operationName, + variables + }; + + const sendRequest = async () => fetch(CONFIG.API_ENDPOINT, { + method: 'POST', + headers: await getAuthHeaders(), + credentials: 'include', + body: JSON.stringify(requestBody), + signal + }); + + let response = await sendRequest(); + if (response.status === 401) { + const errorText = await response.text(); + if (errorText.includes('TOKEN_NEEDS_REFRESH')) { + cachedAccessToken = null; + response = await sendRequest(); + } else { + throw new Error(`${operationName} request failed: 401`); + } + } + + if (!response.ok) { + throw new Error(`${operationName} request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (data.errors?.length) { + throw new Error(`${operationName} failed: ${data.errors[0].message}`); + } + + return data; +} + +function findVisualizationGroup(value) { + if (!value || typeof value !== 'object') return null; + if (Array.isArray(value.dataVisualizationGroupDataSets)) return value; + + for (const child of Object.values(value)) { + const match = findVisualizationGroup(child); + if (match) return match; + } + + return null; +} + +function formattedTextValue(formattedText) { + return formattedText?.spans?.map(span => span.text || '').join('').trim() || ''; +} + +function graphDateToISO(dateLabel) { + const parsedDate = new Date(dateLabel); + if (Number.isNaN(parsedDate.getTime())) return null; + + const year = parsedDate.getFullYear(); + const month = String(parsedDate.getMonth() + 1).padStart(2, '0'); + const day = String(parsedDate.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * Extract the complete graph series. Credit Karma returns overlapping 1M, 3M, + * 6M, YTD, 1Y, and All datasets, so using All avoids duplicate dates. + */ +function extractGraphHistory(data, rootKey, startDate, endDate) { + const graph = findVisualizationGroup(data.data?.prime?.[rootKey]); + if (!graph) { + throw new Error('Credit Karma did not return graph history. The API may have changed.'); + } + + const datasets = graph.dataVisualizationGroupDataSets; + const allDataset = datasets.find(dataset => dataset.dataSetKey === 'All'); + const selectedDataset = allDataset || datasets.reduce((largest, dataset) => { + const pointCount = dataset.dataVisualizationDataSet?.lines + ?.reduce((count, line) => count + (line.points?.length || 0), 0) || 0; + return pointCount > largest.pointCount ? { dataset, pointCount } : largest; + }, { dataset: null, pointCount: -1 }).dataset; + + const start = new Date(`${startDate}T00:00:00`); + const end = new Date(`${endDate}T23:59:59.999`); + const valuesByDate = new Map(); + + for (const line of selectedDataset?.dataVisualizationDataSet?.lines || []) { + for (const point of line.points || []) { + const date = graphDateToISO(formattedTextValue(point.xValueLabel)); + const value = Number(point.yValue); + if (!date || !Number.isFinite(value)) continue; + + const parsedDate = new Date(`${date}T12:00:00`); + if (parsedDate >= start && parsedDate <= end) { + valuesByDate.set(date, value); + } + } + } + + return Array.from(valuesByDate, ([date, value]) => ({ date, value })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +async function fetchGraphHistory(type, startDate, endDate, signal) { + const isInvestments = type === 'investments'; + const data = await fetchPersistedQuery( + isInvestments ? 'getAccountL2Page' : 'getNetworthPage', + isInvestments ? CONFIG.ACCOUNT_L2_HASH : CONFIG.NET_WORTH_HASH, + { + input: isInvestments + ? { accountType: 'investments' } + : { queryStringParameters: '' } + }, + signal + ); + + return extractGraphHistory( + data, + isInvestments ? 'networthByAccountType' : 'networth', + startDate, + endDate + ); +} + /** * Main API entry point. * @@ -920,6 +1055,11 @@ function convertToCSV(transactions, columns) { return header + rows.join(''); } +function convertGraphHistoryToCSV(history, valueColumn) { + const rows = history.map(point => `"${point.date}","${point.value}"\n`); + return `Date,${valueColumn}\n${rows.join('')}`; +} + function saveCSVToFile(csvData, fileName) { const blob = new Blob([csvData], { type: 'text/csv' }); const link = document.createElement('a'); @@ -1307,7 +1447,8 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'captureTransactions') { try { const { startDate, endDate, csvTypes, fetchAccountNames = false, useApi = true, columns } = request; - console.log(`Received request to capture transactions from ${startDate} to ${endDate} (useApi: ${useApi}, fetchAccountNames: ${fetchAccountNames})`); + const needsTransactions = csvTypes.allTransactions || csvTypes.income || csvTypes.expenses; + console.log(`Received export request from ${startDate} to ${endDate}`); // Create a visual indicator that extraction is in progress - moved to left side const indicator = document.createElement('div'); @@ -1321,43 +1462,87 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { indicator.style.borderRadius = '5px'; indicator.style.zIndex = '9999'; indicator.style.fontSize = '14px'; - indicator.textContent = useApi ? 'Extracting transactions via API...' : 'Extracting transactions via scrolling...'; + indicator.textContent = 'Exporting selected Credit Karma data...'; document.body.appendChild(indicator); // Immediately respond to avoid connection issues - sendResponse({ status: 'started', message: 'Transaction capture started' }); + sendResponse({ status: 'started', message: 'Data export started' }); + + const exportData = async () => { + let filteredTransactions = []; + if (needsTransactions) { + ({ filteredTransactions } = await captureTransactionsInDateRange( + startDate, + endDate, + fetchAccountNames, + useApi + )); + } - // Capture transactions (API-first with scroll fallback) - captureTransactionsInDateRange(startDate, endDate, fetchAccountNames, useApi).then(({ allTransactions, filteredTransactions }) => { - console.log(`Capture complete. Found ${filteredTransactions.length} transactions in range`); + if (needsTransactions && filteredTransactions.length === 0) { + console.warn('No transactions found in the specified date range!'); + } else { + if (csvTypes.allTransactions) { + const allCsvData = convertToCSV(filteredTransactions, columns); + saveCSVToFile(allCsvData, `all_transactions_${startDate}_to_${endDate}.csv`); + } - // Remove the indicator - if (indicator.parentNode) indicator.parentNode.removeChild(indicator); + if (csvTypes.income) { + const creditTransactions = filteredTransactions.filter(transaction => transaction.transactionType === 'credit'); + const creditCsvData = convertToCSV(creditTransactions, columns); + saveCSVToFile(creditCsvData, `income_${startDate}_to_${endDate}.csv`); + } - if (filteredTransactions.length === 0) { - console.warn('No transactions found in the specified date range!'); - alert('No transactions found in the specified date range. Make sure the dates are correct and try scrolling manually on the page first.'); - return; + if (csvTypes.expenses) { + const debitTransactions = filteredTransactions.filter(transaction => transaction.transactionType === 'debit'); + const debitCsvData = convertToCSV(debitTransactions, columns); + saveCSVToFile(debitCsvData, `expenses_${startDate}_to_${endDate}.csv`); + } } - // Generate and save CSVs - if (csvTypes.allTransactions) { - const allCsvData = convertToCSV(filteredTransactions, columns); - saveCSVToFile(allCsvData, `all_transactions_${startDate.replace(/\//g, '-')}_to_${endDate.replace(/\//g, '-')}.csv`); + const graphExports = []; + if (csvTypes.netWorth) { + graphExports.push({ + type: 'netWorth', + fileName: `net_worth_${startDate}_to_${endDate}.csv`, + valueColumn: 'Net Worth' + }); } - - if (csvTypes.income) { - const creditTransactions = filteredTransactions.filter(transaction => transaction.transactionType === 'credit'); - const creditCsvData = convertToCSV(creditTransactions, columns); - saveCSVToFile(creditCsvData, `income_${startDate.replace(/\//g, '-')}_to_${endDate.replace(/\//g, '-')}.csv`); + if (csvTypes.investments) { + graphExports.push({ + type: 'investments', + fileName: `investments_${startDate}_to_${endDate}.csv`, + valueColumn: 'Investment Value' + }); } - if (csvTypes.expenses) { - const debitTransactions = filteredTransactions.filter(transaction => transaction.transactionType === 'debit'); - const debitCsvData = convertToCSV(debitTransactions, columns); - saveCSVToFile(debitCsvData, `expenses_${startDate.replace(/\//g, '-')}_to_${endDate.replace(/\//g, '-')}.csv`); + const graphResults = await Promise.all(graphExports.map(async exportConfig => ({ + ...exportConfig, + history: await fetchGraphHistory(exportConfig.type, startDate, endDate) + }))); + + for (const result of graphResults) { + if (result.history.length === 0) { + console.warn(`No ${result.type} graph values found in the selected date range.`); + continue; + } + saveCSVToFile( + convertGraphHistoryToCSV(result.history, result.valueColumn), + result.fileName + ); } + return { transactionCount: filteredTransactions.length, graphResults }; + }; + + exportData().then(({ transactionCount, graphResults }) => { + if (indicator.parentNode) indicator.parentNode.removeChild(indicator); + + const graphPointCount = graphResults.reduce((count, result) => count + result.history.length, 0); + const summaryParts = []; + if (needsTransactions) summaryParts.push(`${transactionCount} transactions`); + if (graphResults.length) summaryParts.push(`${graphPointCount} graph values`); + // Show completion notification - moved to left side const completionNotice = document.createElement('div'); completionNotice.style.position = 'fixed'; @@ -1369,7 +1554,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { completionNotice.style.borderRadius = '5px'; completionNotice.style.zIndex = '9999'; completionNotice.style.fontSize = '14px'; - completionNotice.textContent = `Export complete! Found ${filteredTransactions.length} transactions.`; + completionNotice.textContent = `Export complete: ${summaryParts.join(' and ')}.`; document.body.appendChild(completionNotice); setTimeout(() => { @@ -1378,10 +1563,10 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { }).catch(error => { // Remove the indicator in case of error - document.body.removeChild(indicator); + if (indicator.parentNode) indicator.parentNode.removeChild(indicator); - console.error('Error during transaction capture:', error); - alert(`Error during transaction capture: ${error.message}`); + console.error('Error during data export:', error); + alert(`Error during data export: ${error.message}`); }); } catch (error) { diff --git a/manifest.json b/manifest.json index aaa576e..de623a6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, - "name": "Credit Karma Transactions Exporter", - "version": "2.0", - "description": "Export transactions from Credit Karma within a specified date range.", + "name": "Credit Karma Data Exporter", + "version": "2.1", + "description": "Export transactions, net worth history, and investment values from Credit Karma.", "permissions": [ "activeTab", "scripting" @@ -31,4 +31,4 @@ "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" } -} \ No newline at end of file +} diff --git a/popup.html b/popup.html index c2562e8..904ae94 100644 --- a/popup.html +++ b/popup.html @@ -64,6 +64,12 @@

Files to Generate

+ + @@ -82,7 +88,7 @@

Columns