From 079b6db0f2f834ebb1619a948070fb5962fff586 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 17:11:36 -0500 Subject: [PATCH 01/10] Mostly working --- content.js | 501 +++++++++++++++++++++++++++++++++++++++++++++++++++-- popup.html | 52 +++++- popup.js | 22 ++- 3 files changed, 546 insertions(+), 29 deletions(-) diff --git a/content.js b/content.js index 6b2f0e4..1dfd640 100644 --- a/content.js +++ b/content.js @@ -1,3 +1,429 @@ +// ============================================ +// API-Based Transaction Extraction +// ============================================ + +const API_ENDPOINT = 'https://api.creditkarma.com/graphql'; +const CLIENT_NAME = 'prime_web'; +const CLIENT_VERSION = '2.0.8'; +const DEVICE_TYPE = 'Desktop'; + +// Cache for the access token +let cachedAccessToken = null; + +/** + * Get the access token from CKAT cookie + * The CKAT cookie contains two JWT tokens separated by ; or %3B (URL encoded) + * We need only the first token (access token), not the second (refresh token) + */ +async function getAccessToken() { + // Return cached token if available + if (cachedAccessToken) { + return cachedAccessToken; + } + + // Get the CKAT cookie value + let cookieValue = getCookieValue('CKAT'); + if (cookieValue) { + // URL decode if needed + if (cookieValue.includes('%')) { + try { + cookieValue = decodeURIComponent(cookieValue); + } catch (e) { + console.log('[API] Cookie was not URL encoded'); + } + } + + // The CKAT cookie contains two tokens separated by ; + // First token is access token, second is refresh token + // We only need the first one + const tokens = cookieValue.split(';'); + const accessToken = tokens[0].trim(); + + if (accessToken && accessToken.startsWith('eyJ')) { + console.log('[API] Found access token in CKAT cookie (JWT format)'); + console.log('[API] Token length:', accessToken.length); + cachedAccessToken = accessToken; + return accessToken; + } else { + console.warn('[API] CKAT cookie does not contain valid JWT token'); + } + } + + // Try to extract from page context using script injection + return new Promise((resolve, reject) => { + // Create a unique ID for this request + const requestId = 'ck_token_' + Date.now(); + + // Listen for the response from the injected script + const handler = (event) => { + if (event.data && event.data.type === requestId) { + window.removeEventListener('message', handler); + if (event.data.token) { + console.log('[API] Found access token from page context'); + cachedAccessToken = event.data.token; + resolve(event.data.token); + } else { + reject(new Error('No access token found in page context')); + } + } + }; + window.addEventListener('message', handler); + + // Inject script to get the token from page context + const script = document.createElement('script'); + script.textContent = ` + (function() { + var token = window._ACCESS_TOKEN || null; + window.postMessage({ type: '${requestId}', token: token }, '*'); + })(); + `; + document.documentElement.appendChild(script); + script.remove(); + + // Timeout after 2 seconds + setTimeout(() => { + window.removeEventListener('message', handler); + reject(new Error('Timeout waiting for access token')); + }, 2000); + }); +} + +/** + * Get authentication headers required for API calls + * Extracts tokens and IDs from cookies and page context + */ +async function getAuthHeaders() { + const accessToken = await getAccessToken(); + const cookieId = getCookieValue('CKTRKID'); + const traceId = getCookieValue('CKTRACEID') || generateTraceId(); + + console.log('[API] Auth debug:', { + hasAccessToken: !!accessToken, + tokenLength: accessToken ? accessToken.length : 0, + tokenPrefix: accessToken ? accessToken.substring(0, 20) + '...' : 'none', + hasCookieId: !!cookieId, + hasTraceId: !!traceId + }); + + if (!accessToken) { + throw new Error('No access token found. Please ensure you are logged in to Credit Karma.'); + } + + return { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + 'ck-client-name': CLIENT_NAME, + 'ck-client-version': CLIENT_VERSION, + 'ck-cookie-id': cookieId || '', + 'ck-device-type': DEVICE_TYPE, + 'ck-trace-id': traceId + }; +} + +/** + * Get cookie value by name + */ +function getCookieValue(name) { + const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : null; +} + +/** + * Generate a random trace ID if not available + */ +function generateTraceId() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +/** + * Fetch transactions using the GraphQL API + * Uses GetTransactionsList which returns ALL transactions in a single call + */ +async function fetchTransactionsViaAPI(startDate, endDate, onProgress) { + console.log('[API] Starting API-based transaction fetch'); + + const headers = await getAuthHeaders(); + + // Convert dates for filtering + const startDateTime = new Date(startDate); + const endDateTime = new Date(endDate); + endDateTime.setHours(23, 59, 59, 999); // Include the entire end date + + // GetTransactionsList hash - returns ALL transactions in one call (no pagination needed!) + const TRANSACTIONS_LIST_HASH = 'c3c0a630b5cd938595c5901807f63b807e63c71f54a8fcb55e8c9084cb70832a'; + + if (onProgress) { + onProgress('Fetching all transactions...'); + } + + try { + // Build the simple request body - no pagination needed! + const requestBody = { + extensions: { + persistedQuery: { + sha256Hash: TRANSACTIONS_LIST_HASH, + version: 1 + } + }, + operationName: "GetTransactionsList", + variables: { + input: { + accountInput: {}, + categoryInput: { + categoryId: null, + primeCategoryType: null + } + } + } + }; + + console.log('[API] Sending GetTransactionsList request...'); + + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: headers, + credentials: 'include', + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[API] Response error:', errorText); + + // Handle token refresh + if (response.status === 401 && errorText.includes('TOKEN_NEEDS_REFRESH')) { + console.log('[API] Token needs refresh, clearing cache and retrying...'); + cachedAccessToken = null; + const newHeaders = await getAuthHeaders(); + + // Retry with new token + const retryResponse = await fetch(API_ENDPOINT, { + method: 'POST', + headers: newHeaders, + credentials: 'include', + body: JSON.stringify(requestBody) + }); + + if (!retryResponse.ok) { + throw new Error(`API request failed after token refresh: ${retryResponse.status}`); + } + + const retryData = await retryResponse.json(); + return processTransactionResponse(retryData, startDateTime, endDateTime, onProgress); + } + + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return processTransactionResponse(data, startDateTime, endDateTime, onProgress); + + } catch (error) { + console.error('[API] Error fetching transactions:', error); + throw error; + } +} + +/** + * Process the GetTransactionsList response and filter by date range + */ +function processTransactionResponse(data, startDateTime, endDateTime, onProgress) { + if (data.errors && data.errors.length > 0) { + console.error('[API] GraphQL errors:', JSON.stringify(data.errors, null, 2)); + throw new Error(`GraphQL error: ${data.errors[0].message}`); + } + + // Navigate to the correct response structure + // Different endpoints have different structures: + // - GetTransactions: data.prime.transactionsHub.transactionPage.transactions + // - GetTransactionsList: data.prime.transactionList.transactions + const transactionPage = data.data?.prime?.transactionsHub?.transactionPage; + const transactionList = data.data?.prime?.transactionList; // Note: singular "List" + + console.log('[API] Response keys under prime:', Object.keys(data.data?.prime || {})); + if (transactionList) { + console.log('[API] transactionList keys:', Object.keys(transactionList)); + } + + let transactions = []; + if (transactionPage?.transactions) { + console.log('[API] Found transactions in transactionsHub.transactionPage'); + transactions = transactionPage.transactions; + } else if (transactionList?.transactions) { + console.log('[API] Found transactions in transactionList.transactions'); + transactions = transactionList.transactions; + } else if (Array.isArray(transactionList)) { + console.log('[API] transactionList is an array directly'); + transactions = transactionList; + } else if (data.data?.transactions) { + console.log('[API] Found transactions directly in data'); + transactions = data.data.transactions; + } + + if (!transactions || transactions.length === 0) { + console.log('[API] No transactions in response. Response structure:', Object.keys(data.data || {})); + console.log('[API] Prime structure:', Object.keys(data.data?.prime || {})); + console.log('[API] Full response preview:', JSON.stringify(data).substring(0, 1500)); + return []; + } + + console.log(`[API] Received ${transactions.length} total transactions from API`); + + if (onProgress) { + onProgress(`Processing ${transactions.length} transactions...`); + } + + // Filter and transform transactions + const allTransactions = []; + for (const txn of transactions) { + const transactionDate = new Date(txn.date); + + // Filter by date range + if (transactionDate >= startDateTime && transactionDate <= endDateTime) { + const amount = txn.amount?.value || 0; + const categoryType = txn.category?.type || ''; + const isCredit = amount > 0 || categoryType === 'INCOME'; + + allTransactions.push({ + id: txn.id, + description: txn.description || txn.merchant?.name || '', + category: txn.category?.name || '', + amount: Math.abs(amount), + date: txn.date, + transactionType: isCredit ? 'credit' : 'debit', + accountName: txn.account?.name || '', + accountType: txn.account?.type || '', + provider: txn.account?.providerName || '' + }); + } + } + + console.log(`[API] Filtered to ${allTransactions.length} transactions in date range`); + return allTransactions; +} + +/** + * Fetch transaction detail to get account name + * Used when account info is not available in the list view + */ +async function fetchTransactionDetail(transactionUrn) { + console.log('[API] Fetching transaction detail:', transactionUrn); + + const headers = await getAuthHeaders(); + + try { + const query = { + operationName: "GetTransactionDetail", + variables: { + urn: transactionUrn + }, + query: ` + query GetTransactionDetail($urn: String!) { + transaction(urn: $urn) { + id + urn + description + amount { + value + currencyCode + } + transactionDate + category { + name + } + account { + id + name + accountType + provider { + name + } + } + transactionType + merchant { + name + } + } + } + ` + }; + + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: headers, + credentials: 'include', + body: JSON.stringify(query) + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`); + } + + const data = await response.json(); + + if (data.errors) { + console.warn('[API] Transaction detail errors:', data.errors); + return null; + } + + return data.data?.transaction; + + } catch (error) { + console.error('[API] Error fetching transaction detail:', error); + return null; + } +} + +/** + * Enrich transactions with account names by fetching details + * Only called when fetchAccountNames option is enabled + */ +async function enrichTransactionsWithAccountNames(transactions, onProgress) { + console.log('[API] Enriching transactions with account names'); + + const enrichedTransactions = []; + let processed = 0; + + for (const transaction of transactions) { + // Skip if already has account name + if (transaction.accountName) { + enrichedTransactions.push(transaction); + processed++; + continue; + } + + if (transaction.urn) { + const detail = await fetchTransactionDetail(transaction.urn); + if (detail && detail.account) { + transaction.accountName = detail.account.name || ''; + transaction.accountType = detail.account.accountType || ''; + transaction.provider = detail.account.provider?.name || ''; + } + } + + enrichedTransactions.push(transaction); + processed++; + + if (onProgress && processed % 10 === 0) { + onProgress(`Fetching account details... ${processed}/${transactions.length}`); + } + + // Rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return enrichedTransactions; +} + +// ============================================ +// Original DOM-based extraction (Fallback) +// ============================================ + function convertDateFormat(inputDate) { // Handle various date formats that might appear in Credit Karma var parsedDate; @@ -113,10 +539,12 @@ function filterEmptyTransactions(transactions) { } function convertToCSV(transactions) { - const header = 'Date,Description,Amount,Category,Transaction Type,Account Name,Labels,Notes\n'; - const rows = transactions.map(transaction => - `"${convertDateFormat(transaction.date)}","${transaction.description}","${transaction.amount}","${transaction.category}","${transaction.transactionType}",,,\n` - ); + const header = 'Date,Description,Amount,Category,Transaction Type,Account Name,Account Type,Provider,Labels,Notes\n'; + const rows = transactions.map(transaction => { + // Escape double quotes in strings + const escape = (str) => String(str || '').replace(/"/g, '""'); + return `"${convertDateFormat(transaction.date)}","${escape(transaction.description)}","${transaction.amount}","${escape(transaction.category)}","${transaction.transactionType}","${escape(transaction.accountName || '')}","${escape(transaction.accountType || '')}","${escape(transaction.provider || '')}","",""\n`; + }); return header + rows.join(''); } @@ -146,8 +574,56 @@ function scrollDown() { // Variable to control scrolling let stopScrolling = false; -async function captureTransactionsInDateRange(startDate, endDate) { - console.log(`Starting capture: ${startDate} to ${endDate}`); +/** + * Main capture function - tries API first, falls back to scroll-based extraction + * @param {string} startDate - Start date for the transaction range + * @param {string} endDate - End date for the transaction range + * @param {boolean} fetchAccountNames - Whether to fetch account names for each transaction + * @param {boolean} useApi - Whether to try API extraction first (default: true) + */ +async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNames = false, useApi = true) { + console.log(`Starting capture: ${startDate} to ${endDate} (fetchAccountNames: ${fetchAccountNames}, useApi: ${useApi})`); + + // Try API-based extraction first if enabled + if (useApi) { + try { + console.log('[API] Attempting API-based extraction...'); + let transactions = await fetchTransactionsViaAPI(startDate, endDate, (msg) => { + console.log('[API Progress]', msg); + }); + + if (transactions && transactions.length > 0) { + console.log(`[API] Successfully fetched ${transactions.length} transactions via API`); + + // Optionally enrich with account names + if (fetchAccountNames) { + const needsEnrichment = transactions.filter(t => !t.accountName).length; + if (needsEnrichment > 0) { + console.log(`[API] Enriching ${needsEnrichment} transactions with account names...`); + transactions = await enrichTransactionsWithAccountNames(transactions, (msg) => { + console.log('[API Progress]', msg); + }); + } + } + + // Sort by date (newest first) + transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + return { + allTransactions: transactions, + filteredTransactions: transactions + }; + } + } catch (apiError) { + console.warn('[API] API extraction failed, falling back to scroll method:', apiError.message); + console.error('[API] Full error:', apiError); + console.error('[API] Stack trace:', apiError.stack); + } + } + + // Fallback to scroll-based extraction + console.log('[Scroll] Using scroll-based extraction...'); + let allTransactions = []; const startDateTime = new Date(startDate).getTime(); const endDateTime = new Date(endDate).getTime(); @@ -397,11 +873,12 @@ async function captureTransactionsInDateRange(startDate, endDate) { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'captureTransactions') { try { - const { startDate, endDate, csvTypes } = request; - console.log(`Received request to capture transactions from ${startDate} to ${endDate}`); + const { startDate, endDate, csvTypes, fetchAccountNames = false, useApi = true } = request; + console.log(`Received request to capture transactions from ${startDate} to ${endDate} (useApi: ${useApi}, fetchAccountNames: ${fetchAccountNames})`); - // Create a visual indicator that scraping is in progress - moved to left side + // Create a visual indicator that extraction is in progress - moved to left side const indicator = document.createElement('div'); + indicator.id = 'ck-extractor-indicator'; indicator.style.position = 'fixed'; indicator.style.top = '10px'; indicator.style.left = '20px'; // Changed from right to left @@ -411,14 +888,14 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { indicator.style.borderRadius = '5px'; indicator.style.zIndex = '9999'; indicator.style.fontSize = '14px'; - indicator.textContent = 'Scraping transactions... Please wait.'; + indicator.textContent = useApi ? 'Extracting transactions via API...' : 'Extracting transactions via scrolling...'; document.body.appendChild(indicator); // Immediately respond to avoid connection issues sendResponse({ status: 'started', message: 'Transaction capture started' }); - // Capture transactions - captureTransactionsInDateRange(startDate, endDate).then(({ allTransactions, filteredTransactions }) => { + // 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`); // Remove the indicator diff --git a/popup.html b/popup.html index 31b1098..a1a50e2 100644 --- a/popup.html +++ b/popup.html @@ -1,5 +1,6 @@ + @@ -12,7 +13,7 @@ margin: 0; transition: background-color 0.3s, color 0.3s; } - + h2 { margin-top: 0; margin-bottom: 20px; @@ -201,37 +202,51 @@ border-radius: 50%; } - input:checked + .slider { + input:checked+.slider { background-color: #3f51b5; } - input:checked + .slider:before { + input:checked+.slider:before { transform: translateX(20px); } - .dark-mode input:checked + .slider { + .dark-mode input:checked+.slider { background-color: #7986cb; } .csv-type-selection { margin-bottom: 15px; } + .checkbox-container { display: flex; flex-direction: column; gap: 10px; } + .checkbox-container label { display: flex; align-items: center; gap: 10px; font-size: 14px; } + + .option-helper { + font-size: 11px; + color: #888; + margin: 8px 0 0 0; + font-style: italic; + } + + .dark-mode .option-helper { + color: #aaa; + } +

Export Transactions

- +
+
+

Extraction Options

+
+ + +
+

Account names require individual API calls per transaction.

+
+

Select CSV Types to Export

@@ -295,4 +328,5 @@

Extension Resources

- + + \ No newline at end of file diff --git a/popup.js b/popup.js index 1fe73c0..0798f9f 100644 --- a/popup.js +++ b/popup.js @@ -1,24 +1,30 @@ document.getElementById('export-btn').addEventListener('click', () => { const startDate = document.getElementById('start-date').value; const endDate = document.getElementById('end-date').value; - - // Get checkbox states + + // Get checkbox states - extraction options + const useApiChecked = document.getElementById('useApiCheckbox').checked; + const fetchAccountNamesChecked = document.getElementById('fetchAccountNamesCheckbox').checked; + + // Get checkbox states - CSV types const allTransactionsChecked = document.getElementById('allTransactionsCheckbox').checked; const incomeChecked = document.getElementById('incomeCheckbox').checked; const expensesChecked = document.getElementById('expensesCheckbox').checked; - + if (startDate && endDate) { // Show loading indicator const exportBtn = document.getElementById('export-btn'); const originalText = exportBtn.textContent; exportBtn.textContent = 'Processing...'; exportBtn.disabled = true; - - chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { + + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { chrome.tabs.sendMessage(tabs[0].id, { - action: 'captureTransactions', - startDate, + action: 'captureTransactions', + startDate, endDate, + useApi: useApiChecked, + fetchAccountNames: fetchAccountNamesChecked, csvTypes: { allTransactions: allTransactionsChecked, income: incomeChecked, @@ -30,7 +36,7 @@ document.getElementById('export-btn').addEventListener('click', () => { exportBtn.textContent = originalText; exportBtn.disabled = false; }, 3000); - + if (response) { console.log(response.status); } else { From e4162afba586bf4f8129da8db7f794fbe1754451 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 17:38:31 -0500 Subject: [PATCH 02/10] Better --- content.js | 271 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 250 insertions(+), 21 deletions(-) diff --git a/content.js b/content.js index 1dfd640..4bdb580 100644 --- a/content.js +++ b/content.js @@ -41,7 +41,6 @@ async function getAccessToken() { if (accessToken && accessToken.startsWith('eyJ')) { console.log('[API] Found access token in CKAT cookie (JWT format)'); - console.log('[API] Token length:', accessToken.length); cachedAccessToken = accessToken; return accessToken; } else { @@ -97,13 +96,7 @@ async function getAuthHeaders() { const cookieId = getCookieValue('CKTRKID'); const traceId = getCookieValue('CKTRACEID') || generateTraceId(); - console.log('[API] Auth debug:', { - hasAccessToken: !!accessToken, - tokenLength: accessToken ? accessToken.length : 0, - tokenPrefix: accessToken ? accessToken.substring(0, 20) + '...' : 'none', - hasCookieId: !!cookieId, - hasTraceId: !!traceId - }); + if (!accessToken) { throw new Error('No access token found. Please ensure you are logged in to Credit Karma.'); @@ -141,28 +134,71 @@ function generateTraceId() { } /** - * Fetch transactions using the GraphQL API - * Uses GetTransactionsList which returns ALL transactions in a single call + * Main API entry point: Orchestrates fetching recent + historical data */ async function fetchTransactionsViaAPI(startDate, endDate, onProgress) { - console.log('[API] Starting API-based transaction fetch'); + // 1. Fetch recent data using the fast GetTransactionsList endpoint + console.log('[API] Phase 1: Fetching recent transactions...'); + let transactions = await fetchRecentTransactions(startDate, endDate, onProgress); + + if (!transactions) { + transactions = []; + } + + // Sort to find the oldest date we have + transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + let oldestDate = new Date(); + if (transactions.length > 0) { + oldestDate = new Date(transactions[transactions.length - 1].date); + } + + const reqStartDate = new Date(startDate); + + // Check if we need to fetch older history + // Allow 7 day buffer + const daysDiff = (oldestDate - reqStartDate) / (1000 * 60 * 60 * 24); + + if (daysDiff > 7) { + console.log(`[API] Gap detected: ${daysDiff.toFixed(1)} days. Phase 2: Fetching historical data...`); + if (onProgress) onProgress(`Fetching older history (${daysDiff.toFixed(0)} days gap)...`); + + // Start from the day before our oldest known transaction + const historyEndDate = new Date(oldestDate); + historyEndDate.setDate(historyEndDate.getDate() - 1); + + const historicalTransactions = await fetchHistoricalTransactions(startDate, historyEndDate, onProgress); + + if (historicalTransactions.length > 0) { + console.log(`[API] Merging ${historicalTransactions.length} historical transactions`); + transactions = [...transactions, ...historicalTransactions]; + } + } + + return transactions; +} + +/** + * Fetch recent transactions using GetTransactionsList + */ +async function fetchRecentTransactions(startDate, endDate, onProgress) { + console.log('[API] Starting API-based transaction fetch (Recent)'); const headers = await getAuthHeaders(); // Convert dates for filtering const startDateTime = new Date(startDate); const endDateTime = new Date(endDate); - endDateTime.setHours(23, 59, 59, 999); // Include the entire end date + endDateTime.setHours(23, 59, 59, 999); - // GetTransactionsList hash - returns ALL transactions in one call (no pagination needed!) + // GetTransactionsList hash - returns ALL transactions in one call const TRANSACTIONS_LIST_HASH = 'c3c0a630b5cd938595c5901807f63b807e63c71f54a8fcb55e8c9084cb70832a'; if (onProgress) { - onProgress('Fetching all transactions...'); + onProgress('Fetching all recent transactions...'); } try { - // Build the simple request body - no pagination needed! const requestBody = { extensions: { persistedQuery: { @@ -201,7 +237,6 @@ async function fetchTransactionsViaAPI(startDate, endDate, onProgress) { cachedAccessToken = null; const newHeaders = await getAuthHeaders(); - // Retry with new token const retryResponse = await fetch(API_ENDPOINT, { method: 'POST', headers: newHeaders, @@ -224,11 +259,186 @@ async function fetchTransactionsViaAPI(startDate, endDate, onProgress) { return processTransactionResponse(data, startDateTime, endDateTime, onProgress); } catch (error) { - console.error('[API] Error fetching transactions:', error); - throw error; + console.error('[API] Error fetching recent transactions:', error); + return []; // Return empty to allow fallback or history fetch } } +/** + * Fetch historical transactions using robust pagination + * Since date filtering seems ignored, we page from the top and skip what we already have + */ +async function fetchHistoricalTransactions(startDate, endDate, onProgress) { + console.log(`[API] Starting historical fetch via pagination (Target: < ${convertDateFormat(endDate.toISOString())})`); + + // Hash for the filtered/paginated query + const TRANSACTIONS_QUERY_HASH = 'f669c7e42eb464861cb77d9f27826d0847ddfb5f5079a6ab7e5e2470c9617db8'; + + const targetEndDate = new Date(endDate); // We want transactions OLDER than this + const finalStart = new Date(startDate); + + let allHistoricalTransactions = []; + let hasNextPage = true; + let afterCursor = null; + let pageCount = 0; + let retryCount = 0; + const maxRetries = 3; + + // We expect to skip the first ~12 pages (since we have them from Phase 1) + // But we need to paginate through them to get the cursor for the older data + + while (hasNextPage) { + pageCount++; + if (onProgress) { + if (allHistoricalTransactions.length === 0) { + onProgress(`Scanning history page ${pageCount}...`); + } else { + onProgress(`Fetching history page ${pageCount} (${allHistoricalTransactions.length} txns found)...`); + } + } + + try { + // Refresh headers periodically + const headers = await getAuthHeaders(); + + // Build request + const variables = { + input: { + accountInput: {}, + categoryInput: { + categoryId: null, + primeCategoryType: null + }, + datePeriodInput: { + datePeriod: null // Explicitly null as we are scanning + }, + paginationInput: {} + } + }; + + // Only add cursor if we have one + if (afterCursor) { + variables.input.paginationInput.afterCursor = afterCursor; + } + + const requestBody = { + extensions: { + persistedQuery: { + sha256Hash: TRANSACTIONS_QUERY_HASH, + version: 1 + } + }, + operationName: "GetTransactions", + variables: variables + }; + + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: headers, + credentials: 'include', + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errText = await response.text(); + // Handle token refresh + if (response.status === 401 && errText.includes('TOKEN_NEEDS_REFRESH')) { + console.log('[API] Token refresh needed in history fetch...'); + cachedAccessToken = null; + retryCount++; + if (retryCount <= maxRetries) continue; + } + throw new Error(`History API failed: ${response.status}`); + } + + const data = await response.json(); + + if (data.errors) { + console.error('[API] History GraphQL Errors:', JSON.stringify(data.errors)); + // If it's a pagination error, we might be done + break; + } + + // Parse response + // Structure: data.prime.transactionsHub.transactionPage + const transactionPage = data.data?.prime?.transactionsHub?.transactionPage; + if (!transactionPage) { + console.log('[API] No transactionPage in history response'); + break; + } + + const transactions = transactionPage.transactions || []; + const pageInfo = transactionPage.pageInfo; + + if (transactions.length === 0) { + hasNextPage = false; + break; + } + + // Process transactions + let newItemsInPage = 0; + let oldestInPage = null; + + for (const txn of transactions) { + const txnDate = new Date(txn.date); + oldestInPage = txnDate; + + // If this transaction is NEWER than our targetEndDate, we technically already have it (from Phase 1) + // BUT, duplicate handling is done later. + // Efficiency: Only convert/add if it's <= targetEndDate + + if (txnDate <= targetEndDate && txnDate >= finalStart) { + const amount = txn.amount?.value || 0; + const categoryType = txn.category?.type || ''; + const isCredit = amount > 0 || categoryType === 'INCOME'; + + allHistoricalTransactions.push({ + id: txn.id, + description: txn.description || txn.merchant?.name || '', + category: txn.category?.name || '', + amount: Math.abs(amount), + date: txn.date, + transactionType: isCredit ? 'credit' : 'debit', + accountName: txn.account?.name || '', + accountType: txn.account?.type || '', + provider: txn.account?.providerName || '' + }); + newItemsInPage++; + } + } + + // Check if we are done (passed the start date) + if (oldestInPage && oldestInPage < finalStart) { + console.log(`[API] Reached past start date (${oldestInPage.toISOString()}), stopping history fetch.`); + hasNextPage = false; + break; + } + + // Check page info + hasNextPage = pageInfo?.hasNextPage || false; + afterCursor = pageInfo?.endCursor || null; + + // Rate limiting delay + // Adaptive: If we are scanning (skipping), go faster. If collecting, go slower. + const delay = newItemsInPage > 0 ? 800 : 300; + await new Promise(resolve => setTimeout(resolve, delay)); + + retryCount = 0; // Reset retries on success + + } catch (e) { + console.error('[API] Error in history page:', e); + retryCount++; + if (retryCount > maxRetries) { + console.warn('[API] Max retries hit for history pagination'); + break; + } + await new Promise(resolve => setTimeout(resolve, 2000 * retryCount)); + } + } + + return allHistoricalTransactions; +} + /** * Process the GetTransactionsList response and filter by date range */ @@ -595,6 +805,28 @@ async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNa if (transactions && transactions.length > 0) { console.log(`[API] Successfully fetched ${transactions.length} transactions via API`); + // Sort by date (newest first) + transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + // Check if we covered the full date range + // The API might limit results (e.g. last 1000 txns or 1 year) + // If oldest transaction is still significantly newer than startDate, we assume incomplete data + const oldestTxDate = new Date(transactions[transactions.length - 1].date); + const reqStartDate = new Date(startDate); + + // Allow a small buffer (e.g. 7 days) in case there were just no transactions that week + // But if gap is > 30 days, it's suspicious for most users + const daysDiff = (oldestTxDate - reqStartDate) / (1000 * 60 * 60 * 24); + + + console.log(`[API] Oldest transaction date: ${transactions[transactions.length - 1].date}`); + console.log(`[API] Requested start date: ${startDate}`); + + // We trust the API hybrid strategy (Phase 1 + Phase 2) to have fetched everything possible. + // If there's still a gap, it's likely a legitimate gap in user activity. + // So we do NOT enforce a gap check here anymore. + + // Optionally enrich with account names if (fetchAccountNames) { const needsEnrichment = transactions.filter(t => !t.accountName).length; @@ -606,9 +838,6 @@ async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNa } } - // Sort by date (newest first) - transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - return { allTransactions: transactions, filteredTransactions: transactions From 2114d8700d86bfa2d2887614fa2dc8ee99c7d5bc Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 17:47:40 -0500 Subject: [PATCH 03/10] Fix ui --- content.js | 139 +++++++++++++++++++++++++++++++---------------------- popup.html | 5 -- popup.js | 3 +- 3 files changed, 83 insertions(+), 64 deletions(-) diff --git a/content.js b/content.js index 4bdb580..bebea61 100644 --- a/content.js +++ b/content.js @@ -136,10 +136,10 @@ function generateTraceId() { /** * Main API entry point: Orchestrates fetching recent + historical data */ -async function fetchTransactionsViaAPI(startDate, endDate, onProgress) { +async function fetchTransactionsViaAPI(startDate, endDate, onProgress, signal) { // 1. Fetch recent data using the fast GetTransactionsList endpoint console.log('[API] Phase 1: Fetching recent transactions...'); - let transactions = await fetchRecentTransactions(startDate, endDate, onProgress); + let transactions = await fetchRecentTransactions(startDate, endDate, onProgress, signal); if (!transactions) { transactions = []; @@ -167,7 +167,7 @@ async function fetchTransactionsViaAPI(startDate, endDate, onProgress) { const historyEndDate = new Date(oldestDate); historyEndDate.setDate(historyEndDate.getDate() - 1); - const historicalTransactions = await fetchHistoricalTransactions(startDate, historyEndDate, onProgress); + const historicalTransactions = await fetchHistoricalTransactions(startDate, historyEndDate, onProgress, signal); if (historicalTransactions.length > 0) { console.log(`[API] Merging ${historicalTransactions.length} historical transactions`); @@ -181,7 +181,7 @@ async function fetchTransactionsViaAPI(startDate, endDate, onProgress) { /** * Fetch recent transactions using GetTransactionsList */ -async function fetchRecentTransactions(startDate, endDate, onProgress) { +async function fetchRecentTransactions(startDate, endDate, onProgress, signal) { console.log('[API] Starting API-based transaction fetch (Recent)'); const headers = await getAuthHeaders(); @@ -224,7 +224,8 @@ async function fetchRecentTransactions(startDate, endDate, onProgress) { method: 'POST', headers: headers, credentials: 'include', - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), + signal: signal }); if (!response.ok) { @@ -268,7 +269,7 @@ async function fetchRecentTransactions(startDate, endDate, onProgress) { * Fetch historical transactions using robust pagination * Since date filtering seems ignored, we page from the top and skip what we already have */ -async function fetchHistoricalTransactions(startDate, endDate, onProgress) { +async function fetchHistoricalTransactions(startDate, endDate, onProgress, signal) { console.log(`[API] Starting historical fetch via pagination (Target: < ${convertDateFormat(endDate.toISOString())})`); // Hash for the filtered/paginated query @@ -288,12 +289,17 @@ async function fetchHistoricalTransactions(startDate, endDate, onProgress) { // But we need to paginate through them to get the cursor for the older data while (hasNextPage) { + if (stopScrolling) { + console.log('[API] User requested stop'); + break; + } + pageCount++; if (onProgress) { if (allHistoricalTransactions.length === 0) { onProgress(`Scanning history page ${pageCount}...`); } else { - onProgress(`Fetching history page ${pageCount} (${allHistoricalTransactions.length} txns found)...`); + onProgress(`Fetching history page ${pageCount} (${allHistoricalTransactions.length} transactions found)...`); } } @@ -336,7 +342,8 @@ async function fetchHistoricalTransactions(startDate, endDate, onProgress) { method: 'POST', headers: headers, credentials: 'include', - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), + signal: signal }); if (!response.ok) { @@ -426,6 +433,10 @@ async function fetchHistoricalTransactions(startDate, endDate, onProgress) { retryCount = 0; // Reset retries on success } catch (e) { + if (e.name === 'AbortError') { + console.log('[API] Historical scan aborted by user.'); + break; + } console.error('[API] Error in history page:', e); retryCount++; if (retryCount > maxRetries) { @@ -794,13 +805,63 @@ let stopScrolling = false; async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNames = false, useApi = true) { console.log(`Starting capture: ${startDate} to ${endDate} (fetchAccountNames: ${fetchAccountNames}, useApi: ${useApi})`); + // Reset stop flag + stopScrolling = false; + + // Create stop button (for both API and Scroll modes) + const stopButton = document.createElement('button'); + stopButton.textContent = 'Stop Extraction'; + stopButton.style.position = 'fixed'; + stopButton.style.bottom = '20px'; + stopButton.style.left = '20px'; + stopButton.style.zIndex = '10000'; + stopButton.style.padding = '10px 20px'; + stopButton.style.backgroundColor = '#ff3b30'; + stopButton.style.color = 'white'; + stopButton.style.border = 'none'; + stopButton.style.borderRadius = '5px'; + stopButton.style.fontWeight = 'bold'; + stopButton.style.cursor = 'pointer'; + stopButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; + + // Create AbortController for API requests + const abortController = new AbortController(); + + stopButton.addEventListener('mouseover', () => { stopButton.style.backgroundColor = '#d9342b'; }); + stopButton.addEventListener('mouseout', () => { stopButton.style.backgroundColor = '#ff3b30'; }); + stopButton.addEventListener('click', () => { + stopScrolling = true; + abortController.abort(); // Cancel pending API requests + stopButton.textContent = 'Stopping...'; + stopButton.style.backgroundColor = '#999'; + stopButton.disabled = true; + }); + document.body.appendChild(stopButton); + + // Create info overlay + const counterElement = document.createElement('div'); + counterElement.style.position = 'fixed'; + counterElement.style.bottom = '80px'; + counterElement.style.left = '20px'; + counterElement.style.zIndex = '10000'; + counterElement.style.padding = '10px'; + counterElement.style.backgroundColor = 'rgba(0,0,0,0.7)'; + counterElement.style.color = 'white'; + counterElement.style.borderRadius = '5px'; + counterElement.style.fontSize = '14px'; + counterElement.textContent = 'Initializing...'; + document.body.appendChild(counterElement); + // Try API-based extraction first if enabled if (useApi) { try { console.log('[API] Attempting API-based extraction...'); + counterElement.textContent = 'Connecting to Credit Karma API...'; + let transactions = await fetchTransactionsViaAPI(startDate, endDate, (msg) => { console.log('[API Progress]', msg); - }); + counterElement.textContent = msg; + }, abortController.signal); if (transactions && transactions.length > 0) { console.log(`[API] Successfully fetched ${transactions.length} transactions via API`); @@ -828,22 +889,33 @@ async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNa // Optionally enrich with account names - if (fetchAccountNames) { + if (fetchAccountNames && !stopScrolling) { const needsEnrichment = transactions.filter(t => !t.accountName).length; if (needsEnrichment > 0) { console.log(`[API] Enriching ${needsEnrichment} transactions with account names...`); transactions = await enrichTransactionsWithAccountNames(transactions, (msg) => { console.log('[API Progress]', msg); + counterElement.textContent = msg; }); } } + // Cleanup UI elements before returning + if (stopButton.parentNode) stopButton.parentNode.removeChild(stopButton); + if (counterElement.parentNode) counterElement.parentNode.removeChild(counterElement); + return { allTransactions: transactions, filteredTransactions: transactions }; } } catch (apiError) { + if (apiError.name === 'AbortError') { + console.log('[API] Extraction aborted by user.'); + if (stopButton.parentNode) stopButton.parentNode.removeChild(stopButton); + if (counterElement.parentNode) counterElement.parentNode.removeChild(counterElement); + return { allTransactions: [], filteredTransactions: [] }; + } console.warn('[API] API extraction failed, falling back to scroll method:', apiError.message); console.error('[API] Full error:', apiError); console.error('[API] Stack trace:', apiError.stack); @@ -862,54 +934,7 @@ async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNa let foundTargetDateRange = false; let consecutiveTargetDateMatches = 0; - // Reset stop flag - stopScrolling = false; - - // Create stop button - moved to left side - const stopButton = document.createElement('button'); - stopButton.textContent = 'Stop Scrolling'; - stopButton.style.position = 'fixed'; - stopButton.style.bottom = '20px'; - stopButton.style.left = '20px'; // Changed from right to left - stopButton.style.zIndex = '10000'; - stopButton.style.padding = '10px 20px'; - stopButton.style.backgroundColor = '#ff3b30'; - stopButton.style.color = 'white'; - stopButton.style.border = 'none'; - stopButton.style.borderRadius = '5px'; - stopButton.style.fontWeight = 'bold'; - stopButton.style.cursor = 'pointer'; - stopButton.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; - - // Add hover effect - stopButton.addEventListener('mouseover', () => { - stopButton.style.backgroundColor = '#d9342b'; - }); - stopButton.addEventListener('mouseout', () => { - stopButton.style.backgroundColor = '#ff3b30'; - }); - - stopButton.addEventListener('click', () => { - stopScrolling = true; - stopButton.textContent = 'Stopping...'; - stopButton.style.backgroundColor = '#999'; - stopButton.disabled = true; - }); - - document.body.appendChild(stopButton); - // Update counter element - moved to left side - const counterElement = document.createElement('div'); - counterElement.style.position = 'fixed'; - counterElement.style.bottom = '80px'; - counterElement.style.left = '20px'; // Changed from right to left - counterElement.style.zIndex = '10000'; - counterElement.style.padding = '10px'; - counterElement.style.backgroundColor = 'rgba(0,0,0,0.7)'; - counterElement.style.color = 'white'; - counterElement.style.borderRadius = '5px'; - counterElement.style.fontSize = '14px'; - document.body.appendChild(counterElement); try { // Helper function to wait for DOM to stabilize after scroll diff --git a/popup.html b/popup.html index a1a50e2..1e35617 100644 --- a/popup.html +++ b/popup.html @@ -272,12 +272,7 @@

Extraction Options

Use API (faster, recommended) - -

Account names require individual API calls per transaction.

diff --git a/popup.js b/popup.js index 0798f9f..49e058e 100644 --- a/popup.js +++ b/popup.js @@ -4,7 +4,6 @@ document.getElementById('export-btn').addEventListener('click', () => { // Get checkbox states - extraction options const useApiChecked = document.getElementById('useApiCheckbox').checked; - const fetchAccountNamesChecked = document.getElementById('fetchAccountNamesCheckbox').checked; // Get checkbox states - CSV types const allTransactionsChecked = document.getElementById('allTransactionsCheckbox').checked; @@ -24,7 +23,7 @@ document.getElementById('export-btn').addEventListener('click', () => { startDate, endDate, useApi: useApiChecked, - fetchAccountNames: fetchAccountNamesChecked, + fetchAccountNames: false, csvTypes: { allTransactions: allTransactionsChecked, income: incomeChecked, From e3ba4acf3b2fb962cd25b7b1e3f6faeb3d7b9f00 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 18:06:22 -0500 Subject: [PATCH 04/10] Modern UI --- content.js | 54 ++++++-- popup.css | 320 +++++++++++++++++++++++++++++++++++++++++--- popup.html | 379 ++++++++++++----------------------------------------- popup.js | 166 +++++++++++++++-------- 4 files changed, 540 insertions(+), 379 deletions(-) diff --git a/content.js b/content.js index bebea61..a2be284 100644 --- a/content.js +++ b/content.js @@ -759,13 +759,49 @@ function filterEmptyTransactions(transactions) { ); } -function convertToCSV(transactions) { - const header = 'Date,Description,Amount,Category,Transaction Type,Account Name,Account Type,Provider,Labels,Notes\n'; +function convertToCSV(transactions, columns) { + // Default to all true if columns not provided + const cols = columns || { + date: true, description: true, amount: true, category: true, + type: true, account: true, notes: true, labels: true + }; + + let headerParts = []; + if (cols.date) headerParts.push('Date'); + if (cols.description) headerParts.push('Description'); + if (cols.amount) headerParts.push('Amount'); + if (cols.category) headerParts.push('Category'); + if (cols.type) headerParts.push('Transaction Type'); + if (cols.account) { + headerParts.push('Account Name'); + headerParts.push('Account Type'); + headerParts.push('Provider'); + } + if (cols.labels) headerParts.push('Labels'); + if (cols.notes) headerParts.push('Notes'); + + const header = headerParts.join(',') + '\n'; + const rows = transactions.map(transaction => { - // Escape double quotes in strings const escape = (str) => String(str || '').replace(/"/g, '""'); - return `"${convertDateFormat(transaction.date)}","${escape(transaction.description)}","${transaction.amount}","${escape(transaction.category)}","${transaction.transactionType}","${escape(transaction.accountName || '')}","${escape(transaction.accountType || '')}","${escape(transaction.provider || '')}","",""\n`; + let rowParts = []; + + if (cols.date) rowParts.push(`"${convertDateFormat(transaction.date)}"`); + if (cols.description) rowParts.push(`"${escape(transaction.description)}"`); + if (cols.amount) rowParts.push(`"${transaction.amount}"`); + if (cols.category) rowParts.push(`"${escape(transaction.category)}"`); + if (cols.type) rowParts.push(`"${transaction.transactionType}"`); + if (cols.account) { + rowParts.push(`"${escape(transaction.accountName || '')}"`); + rowParts.push(`"${escape(transaction.accountType || '')}"`); + rowParts.push(`"${escape(transaction.provider || '')}"`); + } + if (cols.labels) rowParts.push(`""`); // Placeholder + if (cols.notes) rowParts.push(`""`); // Placeholder + + return rowParts.join(',') + '\n'; }); + return header + rows.join(''); } @@ -1127,7 +1163,7 @@ async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNa chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'captureTransactions') { try { - const { startDate, endDate, csvTypes, fetchAccountNames = false, useApi = true } = request; + 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})`); // Create a visual indicator that extraction is in progress - moved to left side @@ -1153,7 +1189,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { console.log(`Capture complete. Found ${filteredTransactions.length} transactions in range`); // Remove the indicator - document.body.removeChild(indicator); + if (indicator.parentNode) indicator.parentNode.removeChild(indicator); if (filteredTransactions.length === 0) { console.warn('No transactions found in the specified date range!'); @@ -1163,19 +1199,19 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { // Generate and save CSVs if (csvTypes.allTransactions) { - const allCsvData = convertToCSV(filteredTransactions); + const allCsvData = convertToCSV(filteredTransactions, columns); saveCSVToFile(allCsvData, `all_transactions_${startDate.replace(/\//g, '-')}_to_${endDate.replace(/\//g, '-')}.csv`); } if (csvTypes.income) { const creditTransactions = filteredTransactions.filter(transaction => transaction.transactionType === 'credit'); - const creditCsvData = convertToCSV(creditTransactions); + const creditCsvData = convertToCSV(creditTransactions, columns); saveCSVToFile(creditCsvData, `income_${startDate.replace(/\//g, '-')}_to_${endDate.replace(/\//g, '-')}.csv`); } if (csvTypes.expenses) { const debitTransactions = filteredTransactions.filter(transaction => transaction.transactionType === 'debit'); - const debitCsvData = convertToCSV(debitTransactions); + const debitCsvData = convertToCSV(debitTransactions, columns); saveCSVToFile(debitCsvData, `expenses_${startDate.replace(/\//g, '-')}_to_${endDate.replace(/\//g, '-')}.csv`); } diff --git a/popup.css b/popup.css index c7a7567..6827d51 100644 --- a/popup.css +++ b/popup.css @@ -1,31 +1,311 @@ +:root { + --primary: #3b82f6; + /* Blue */ + --primary-hover: #2563eb; + --bg: #f8f9fa; + --surface: #ffffff; + --text: #1f2937; + --text-secondary: #6b7280; + --border: #e5e7eb; + --shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --radius: 10px; +} + +[data-theme="dark"] { + --bg: #111827; + --surface: #1f2937; + --text: #f9fafb; + --text-secondary: #9ca3af; + --border: #374151; + --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.5); +} + body { - width: 300px; - padding: 10px; - font-family: Arial, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background-color: var(--bg); + color: var(--text); + margin: 0; + width: 380px; + padding: 12px; + /* Reduced padding */ + transition: background-color 0.3s, color 0.3s; + box-sizing: border-box; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + /* Reduced margin */ } h1 { - font-size: 18px; + font-size: 18px; + font-weight: 700; + margin: 0; + color: var(--primary); + /* Solid color instead of gradient for cleaner look */ +} + +.theme-toggle { + cursor: pointer; + padding: 6px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.2s; +} + +.theme-toggle:hover { + background: var(--bg); + color: var(--primary); +} + +.card { + background: var(--surface); + border-radius: var(--radius); + padding: 12px; + /* Reduced padding */ + margin-bottom: 12px; + /* Reduced margin */ + box-shadow: var(--shadow); + border: 1px solid var(--border); +} + +.section-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin: 0 0 8px 0; + font-weight: 600; +} + +/* Inputs */ +.input-row { + display: flex; + gap: 10px; +} + +.input-group { + flex: 1; } -label, input, button { - display: block; - width: 100%; - margin: 5px 0; +label { + display: block; + font-size: 12px; + margin-bottom: 4px; + font-weight: 500; + color: var(--text); } -button.green { - background-color: green; - color: white; - padding: 10px; - border: none; - cursor: pointer; +input[type="date"] { + width: 100%; + padding: 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 12px; + outline: none; + box-sizing: border-box; } -button.red { - background-color: red; - color: white; - padding: 10px; - border: none; - cursor: pointer; +input[type="date"]:focus { + border-color: var(--primary); } + +/* Quick Date Pills */ +.quick-dates { + display: flex; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; +} + +.pill-btn { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 4px 10px; + border-radius: 12px; + font-size: 10px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s; + flex: 1; + text-align: center; +} + +.pill-btn:hover { + border-color: var(--primary); + color: var(--primary); + background: var(--surface); +} + +/* Force Calendar Theme */ +input[type="date"] { + color-scheme: light; +} + +[data-theme="dark"] input[type="date"] { + color-scheme: dark; +} + +/* Checkboxes Grid */ +.grid-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + cursor: pointer; + user-select: none; +} + +.checkbox-label input { + width: 14px; + height: 14px; + border-radius: 3px; + accent-color: var(--primary); + margin: 0; +} + +/* Toggle Switch */ +.toggle-row { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} + +.toggle-switch { + position: relative; + width: 36px; + height: 20px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border); + transition: .3s; + border-radius: 20px; +} + +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + transition: .3s; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +input:checked+.slider { + background-color: var(--primary); +} + +input:checked+.slider:before { + transform: translateX(16px); +} + +/* Button */ +.primary-btn { + width: 100%; + padding: 12px; + background: var(--primary); + color: white; + border: none; + border-radius: var(--radius); + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: opacity 0.2s, background-color 0.2s; + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3); +} + +.primary-btn:hover { + background: var(--primary-hover); +} + +.primary-btn:active { + transform: scale(0.98); +} + +.primary-btn:disabled { + background: var(--text-secondary); + cursor: not-allowed; + box-shadow: none; +} + +/* Footer */ +footer { + margin-top: 16px; + border-top: 1px solid var(--border); + padding-top: 12px; +} + +.footer-section { + margin-bottom: 12px; +} + +.footer-section h4 { + font-size: 11px; + text-transform: uppercase; + color: var(--text-secondary); + margin: 0 0 6px 0; + font-weight: 600; +} + +.footer-links { + display: flex; + flex-direction: column; + gap: 6px; +} + +.footer-links a { + font-size: 11px; + color: var(--primary); + text-decoration: none; + display: flex; + align-items: center; + gap: 4px; +} + +.footer-links a:hover { + text-decoration: underline; +} + +.footer-meta { + font-size: 10px; + color: var(--text-secondary); + text-align: center; + margin-top: 8px; + line-height: 1.4; +} \ No newline at end of file diff --git a/popup.html b/popup.html index 1e35617..a23b718 100644 --- a/popup.html +++ b/popup.html @@ -4,303 +4,94 @@ - Credit Karma Exporter - + Export Transactions + - -

Export Transactions

- -
- - Dark Mode -
- -
- - -
- -
- - -
- -
-

Extraction Options

-
- + +
+

Credit Karma Extractor

+
+ + +
-
- -
-

Select CSV Types to Export

-
- - -
-
+ + +
+

Files to Generate

+
+ + + +
+
+ +
+

Columns

+
+ + + + + + + + +
+
- + + - From d98f7cdc26143f243025cba70c7dfc72f21c376a Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 18:16:36 -0500 Subject: [PATCH 06/10] Add screenshots section to README Added a screenshots section to the README. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 675c530..8fcf3a7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ The **Credit Karma Transaction Extractor** allows you to instantly export your e **πŸŽ‰ 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. +## Screenshots +image + ## Features - **Instant Extraction**: Uses the specific `GetTransactions` API to fetch data 100x faster than scrolling. From 33141e7d8d02a71b77db65b60be98a8881b0b596 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 18:25:49 -0500 Subject: [PATCH 07/10] Github action --- .github/workflows/build-extension.yml | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/build-extension.yml diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml new file mode 100644 index 0000000..708869b --- /dev/null +++ b/.github/workflows/build-extension.yml @@ -0,0 +1,44 @@ +name: Build Extension + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - 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@v3 + with: + name: credit-karma-extractor + path: extension.zip From e41e035661301fff867c25cefe980543423bc21d Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 18:28:35 -0500 Subject: [PATCH 08/10] Update ver --- .github/workflows/build-extension.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml index 708869b..1e48f67 100644 --- a/.github/workflows/build-extension.yml +++ b/.github/workflows/build-extension.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | @@ -38,7 +38,7 @@ jobs: echo "Extension packaged successfully ($(du -h extension.zip | cut -f1))" - name: Upload Extension Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: credit-karma-extractor path: extension.zip From a12e7f90650f0fbb3a88f11a42baead7366e54a3 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 18:33:25 -0500 Subject: [PATCH 09/10] Readme updates --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8fcf3a7..81f4c91 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Credit Karma Transaction 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) ![GitHub forks](https://img.shields.io/github/forks/cbangera2/CreditKarmaExtractor?style=social) ![GitHub top language](https://img.shields.io/github/languages/top/cbangera2/CreditKarmaExtractor) From 7ba21b7c3ee500b66c341c8108efb238dd70013e Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Sat, 3 Jan 2026 18:37:17 -0500 Subject: [PATCH 10/10] small refactor --- content.js | 52 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/content.js b/content.js index a2be284..58f7bf6 100644 --- a/content.js +++ b/content.js @@ -2,10 +2,16 @@ // API-Based Transaction Extraction // ============================================ -const API_ENDPOINT = 'https://api.creditkarma.com/graphql'; -const CLIENT_NAME = 'prime_web'; -const CLIENT_VERSION = '2.0.8'; -const DEVICE_TYPE = 'Desktop'; +const CONFIG = { + API_ENDPOINT: 'https://api.creditkarma.com/graphql', + CLIENT_NAME: 'prime_web', + CLIENT_VERSION: '2.0.8', + DEVICE_TYPE: 'Desktop', + // 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' +}; // Cache for the access token let cachedAccessToken = null; @@ -106,10 +112,10 @@ async function getAuthHeaders() { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, - 'ck-client-name': CLIENT_NAME, - 'ck-client-version': CLIENT_VERSION, + 'ck-client-name': CONFIG.CLIENT_NAME, + 'ck-client-version': CONFIG.CLIENT_VERSION, 'ck-cookie-id': cookieId || '', - 'ck-device-type': DEVICE_TYPE, + 'ck-device-type': CONFIG.DEVICE_TYPE, 'ck-trace-id': traceId }; } @@ -192,7 +198,7 @@ async function fetchRecentTransactions(startDate, endDate, onProgress, signal) { endDateTime.setHours(23, 59, 59, 999); // GetTransactionsList hash - returns ALL transactions in one call - const TRANSACTIONS_LIST_HASH = 'c3c0a630b5cd938595c5901807f63b807e63c71f54a8fcb55e8c9084cb70832a'; + // Uses CONFIG.TRANSACTIONS_LIST_HASH if (onProgress) { onProgress('Fetching all recent transactions...'); @@ -202,7 +208,7 @@ async function fetchRecentTransactions(startDate, endDate, onProgress, signal) { const requestBody = { extensions: { persistedQuery: { - sha256Hash: TRANSACTIONS_LIST_HASH, + sha256Hash: CONFIG.TRANSACTIONS_LIST_HASH, version: 1 } }, @@ -220,7 +226,7 @@ async function fetchRecentTransactions(startDate, endDate, onProgress, signal) { console.log('[API] Sending GetTransactionsList request...'); - const response = await fetch(API_ENDPOINT, { + const response = await fetch(CONFIG.API_ENDPOINT, { method: 'POST', headers: headers, credentials: 'include', @@ -238,7 +244,7 @@ async function fetchRecentTransactions(startDate, endDate, onProgress, signal) { cachedAccessToken = null; const newHeaders = await getAuthHeaders(); - const retryResponse = await fetch(API_ENDPOINT, { + const retryResponse = await fetch(CONFIG.API_ENDPOINT, { method: 'POST', headers: newHeaders, credentials: 'include', @@ -273,7 +279,7 @@ async function fetchHistoricalTransactions(startDate, endDate, onProgress, signa console.log(`[API] Starting historical fetch via pagination (Target: < ${convertDateFormat(endDate.toISOString())})`); // Hash for the filtered/paginated query - const TRANSACTIONS_QUERY_HASH = 'f669c7e42eb464861cb77d9f27826d0847ddfb5f5079a6ab7e5e2470c9617db8'; + // Uses CONFIG.TRANSACTIONS_QUERY_HASH const targetEndDate = new Date(endDate); // We want transactions OLDER than this const finalStart = new Date(startDate); @@ -330,7 +336,7 @@ async function fetchHistoricalTransactions(startDate, endDate, onProgress, signa const requestBody = { extensions: { persistedQuery: { - sha256Hash: TRANSACTIONS_QUERY_HASH, + sha256Hash: CONFIG.TRANSACTIONS_QUERY_HASH, version: 1 } }, @@ -338,7 +344,7 @@ async function fetchHistoricalTransactions(startDate, endDate, onProgress, signa variables: variables }; - const response = await fetch(API_ENDPOINT, { + const response = await fetch(CONFIG.API_ENDPOINT, { method: 'POST', headers: headers, credentials: 'include', @@ -456,6 +462,13 @@ async function fetchHistoricalTransactions(startDate, endDate, onProgress, signa function processTransactionResponse(data, startDateTime, endDateTime, onProgress) { if (data.errors && data.errors.length > 0) { console.error('[API] GraphQL errors:', JSON.stringify(data.errors, null, 2)); + + // Check for outdated persistent queries + const errorStr = JSON.stringify(data.errors); + if (errorStr.includes('PersistedQueryNotFound') || errorStr.includes('PERSISTED_QUERY_NOT_FOUND')) { + throw new Error('API Schema mismatch. The extension likely needs an update to match Credit Karma\'s new API.'); + } + throw new Error(`GraphQL error: ${data.errors[0].message}`); } @@ -574,7 +587,7 @@ async function fetchTransactionDetail(transactionUrn) { ` }; - const response = await fetch(API_ENDPOINT, { + const response = await fetch(CONFIG.API_ENDPOINT, { method: 'POST', headers: headers, credentials: 'include', @@ -890,11 +903,12 @@ async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNa // Try API-based extraction first if enabled if (useApi) { + let transactions = []; try { console.log('[API] Attempting API-based extraction...'); counterElement.textContent = 'Connecting to Credit Karma API...'; - let transactions = await fetchTransactionsViaAPI(startDate, endDate, (msg) => { + transactions = await fetchTransactionsViaAPI(startDate, endDate, (msg) => { console.log('[API Progress]', msg); counterElement.textContent = msg; }, abortController.signal); @@ -950,7 +964,11 @@ async function captureTransactionsInDateRange(startDate, endDate, fetchAccountNa console.log('[API] Extraction aborted by user.'); if (stopButton.parentNode) stopButton.parentNode.removeChild(stopButton); if (counterElement.parentNode) counterElement.parentNode.removeChild(counterElement); - return { allTransactions: [], filteredTransactions: [] }; + // Return partial results if we have them + return { + allTransactions: transactions || [], + filteredTransactions: transactions || [] + }; } console.warn('[API] API extraction failed, falling back to scroll method:', apiError.message); console.error('[API] Full error:', apiError);