diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml
new file mode 100644
index 0000000..1e48f67
--- /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@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
diff --git a/README.md b/README.md
index 66b07f5..81f4c91 100644
--- a/README.md
+++ b/README.md
@@ -1,114 +1,72 @@
# Credit Karma Transaction Extractor Chrome Extension
+[](https://github.com/cbangera2/CreditKarmaExtractor/actions/workflows/build-extension.yml)


-
-
-


-The **Credit Karma Transaction Extractor** Chrome extension allows you to easily export your Credit Karma transactions to CSV format for analysis in your preferred financial tools. Whether you're tracking expenses, analyzing spending patterns, or maintaining financial records, this extension streamlines the data extraction process.
+The **Credit Karma Transaction Extractor** allows you to instantly export your entire transaction history from Credit Karma to CSV.
-## Screenshots
-
-
+**π 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
+
## Features
-- **Date Range Selection**: Choose specific start and end dates for transaction export
-- **Smart Data Export**: Automatically generates three CSV files:
- - `all_transactions.csv`: Complete transaction history
- - `expenses.csv`: Debit transactions only
- - `income.csv`: Credit transactions only
-- **Dark Mode Support**: Seamless experience in both light and dark themes
-- **Automatic Scrolling**: Intelligently scrolls through all transactions in the selected date range
-- **CSV Format**: Export data in a format compatible with popular financial tools
+- **Instant Extraction**: Uses the specific `GetTransactions` API to fetch data 100x faster than scrolling.
+- **Deep History**: Easily download transactions from years ago without waiting for the page to load.
+- **Customizable Output**: Choose exactly which columns you want (Date, Description, Amount, Category, Type, Account, Notes, Labels).
+- **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.
## Quick Start
1. **Install the Extension**:
- - Clone this repository or download the files
- - Open Chrome and go to `chrome://extensions/`
- - Enable "Developer mode" (top right)
- - Click "Load unpacked" and select the extension directory
+ - Clone this repository or download the files.
+ - Open Chrome and go to `chrome://extensions/`.
+ - Enable **Developer mode** (top right toggle).
+ - Click **Load unpacked** and select the extension directory.
2. **Export Transactions**:
- - Go to [Credit Karma Transactions](https://www.creditkarma.com/networth/transactions)
- - Click the extension icon
- - Select your date range
- - Click "Export" and wait for the files to download
+ - 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**.
+ - Watch the progress indicator and wait for your CSV files!
## Analyze Your Data
-After exporting, you have several options to analyze your transactions:
+Once you have your CSVs, you can use them with:
-1. **[BudgetLens](https://github.com/cbangera2/BudgetLens)**: Modern web dashboard for visualizing your financial data
-2. **[TMOAP Budget Tool](https://themeasureofaplan.com/budget-tracking-tool/)**: Comprehensive spreadsheet for detailed financial analysis
-3. Import into your preferred spreadsheet or financial software
+1. **[BudgetLens](https://github.com/cbangera2/BudgetLens)**: Modern web dashboard for visualizing your financial data.
+2. **[TMOAP Budget Tool](https://themeasureofaplan.com/budget-tracking-tool/)**: Comprehensive spreadsheet for detailed financial analysis.
+3. Any spreadsheet software (Excel, Google Sheets, Numbers).
## Changelog
+### 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.
+- **Column Selection**: Users can now filter specific columns from the CSV.
+- **Performance**: Extraction is now near-instantaneous.
+- **Robustness**: Better error handling and "Instant Stop" functionality using AbortController.
+
### Version 1.2 (March 2025)
-- Added real-time transaction counter during extraction
-- Added "Stop Scrolling" button to manually end extraction process
-- Enhanced date parsing for better historical data extraction
-- Added adaptive scrolling speeds to optimize performance
-
-### Version 1.1 (December 2024)
-- Added dark mode support with toggle switch
-- Improved date range scrolling functionality
-- Enhanced UI with modern design and better organization
-- Added quick links to analysis tools
-- Fixed cross-month date range scrolling issues
-- Improved security with updated content security policy
-- Enhanced responsive design for better usability
-
-### Version 1.0 (May 2024)
-- Initial release
-- Basic transaction extraction functionality
-- Date range selection
-- CSV export capability
-- Auto-scrolling feature
-
-## Troubleshooting
-
-If you encounter issues:
-
-1. **No Data Extracted**
- - Verify you're logged into Credit Karma
- - Ensure you're on the correct transactions page
- - Check if the date range is valid
-
-2. **Scrolling Issues**
- - Try selecting a smaller date range
- - Refresh the page and try again
- - Check console for any error messages
-
-## Roadmap
-
-- Transaction search and filtering
-- Additional export formats (JSON, Excel)
-- Smart transaction categorization
-- Progress indicator during extraction
-- Batch processing for multiple date ranges
-- Enhanced error handling and recovery
-- Custom category mapping
+- Added real-time transaction counter.
+- Basic "Stop" functionality.
+- Adaptive scrolling speeds.
## Contributing
Contributions are welcome! Feel free to:
-
-1. Fork the repository
-2. Create a feature branch
-3. Submit a pull request
-
-For major changes, please open an issue first to discuss the proposed changes.
-
+1. Fork the repository.
+2. Create a feature branch.
+3. Submit a pull request.
## Credits
- Developed by [Chirag Bangera](https://github.com/cbangera2).
----
-*This extension is not affiliated with or endorsed by Credit Karma.
+- Not affiliated with or endorsed by Credit Karma.
diff --git a/content.js b/content.js
index 6b2f0e4..58f7bf6 100644
--- a/content.js
+++ b/content.js
@@ -1,3 +1,663 @@
+// ============================================
+// API-Based Transaction Extraction
+// ============================================
+
+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;
+
+/**
+ * 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)');
+ 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();
+
+
+
+ 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': CONFIG.CLIENT_NAME,
+ 'ck-client-version': CONFIG.CLIENT_VERSION,
+ 'ck-cookie-id': cookieId || '',
+ 'ck-device-type': CONFIG.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);
+ });
+}
+
+/**
+ * Main API entry point: Orchestrates fetching recent + historical data
+ */
+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, signal);
+
+ 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, signal);
+
+ 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, signal) {
+ 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);
+
+ // GetTransactionsList hash - returns ALL transactions in one call
+ // Uses CONFIG.TRANSACTIONS_LIST_HASH
+
+ if (onProgress) {
+ onProgress('Fetching all recent transactions...');
+ }
+
+ try {
+ const requestBody = {
+ extensions: {
+ persistedQuery: {
+ sha256Hash: CONFIG.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(CONFIG.API_ENDPOINT, {
+ method: 'POST',
+ headers: headers,
+ credentials: 'include',
+ body: JSON.stringify(requestBody),
+ signal: signal
+ });
+
+ 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();
+
+ const retryResponse = await fetch(CONFIG.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 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, signal) {
+ console.log(`[API] Starting historical fetch via pagination (Target: < ${convertDateFormat(endDate.toISOString())})`);
+
+ // Hash for the filtered/paginated query
+ // Uses CONFIG.TRANSACTIONS_QUERY_HASH
+
+ 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) {
+ 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} transactions 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: CONFIG.TRANSACTIONS_QUERY_HASH,
+ version: 1
+ }
+ },
+ operationName: "GetTransactions",
+ variables: variables
+ };
+
+ const response = await fetch(CONFIG.API_ENDPOINT, {
+ method: 'POST',
+ headers: headers,
+ credentials: 'include',
+ body: JSON.stringify(requestBody),
+ signal: signal
+ });
+
+ 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) {
+ 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) {
+ 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
+ */
+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}`);
+ }
+
+ // 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(CONFIG.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;
@@ -112,11 +772,49 @@ 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`
- );
+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 => {
+ const escape = (str) => String(str || '').replace(/"/g, '""');
+ 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('');
}
@@ -146,26 +844,25 @@ function scrollDown() {
// Variable to control scrolling
let stopScrolling = false;
-async function captureTransactionsInDateRange(startDate, endDate) {
- console.log(`Starting capture: ${startDate} to ${endDate}`);
- let allTransactions = [];
- const startDateTime = new Date(startDate).getTime();
- const endDateTime = new Date(endDate).getTime();
- let lastTransactionCount = 0;
- let unchangedCount = 0;
- let scrollAttempts = 0;
- let foundTargetDateRange = false;
- let consecutiveTargetDateMatches = 0;
+/**
+ * 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})`);
// Reset stop flag
stopScrolling = false;
- // Create stop button - moved to left side
+ // Create stop button (for both API and Scroll modes)
const stopButton = document.createElement('button');
- stopButton.textContent = 'Stop Scrolling';
+ stopButton.textContent = 'Stop Extraction';
stopButton.style.position = 'fixed';
stopButton.style.bottom = '20px';
- stopButton.style.left = '20px'; // Changed from right to left
+ stopButton.style.left = '20px';
stopButton.style.zIndex = '10000';
stopButton.style.padding = '10px 20px';
stopButton.style.backgroundColor = '#ff3b30';
@@ -176,36 +873,123 @@ async function captureTransactionsInDateRange(startDate, endDate) {
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';
- });
+ // 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);
- // Update counter element - moved to left side
+ // Create info overlay
const counterElement = document.createElement('div');
counterElement.style.position = 'fixed';
counterElement.style.bottom = '80px';
- counterElement.style.left = '20px'; // Changed from right to left
+ 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) {
+ let transactions = [];
+ try {
+ console.log('[API] Attempting API-based extraction...');
+ counterElement.textContent = 'Connecting to Credit Karma API...';
+
+ 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`);
+
+ // 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 && !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 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);
+ 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();
+ let lastTransactionCount = 0;
+ let unchangedCount = 0;
+ let scrollAttempts = 0;
+ let foundTargetDateRange = false;
+ let consecutiveTargetDateMatches = 0;
+
+
+
try {
// Helper function to wait for DOM to stabilize after scroll
const waitForDOMUpdate = async (timeout = 2000) => {
@@ -397,11 +1181,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, columns } = 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,18 +1196,18 @@ 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
- 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!');
@@ -432,19 +1217,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/manifest.json b/manifest.json
index 830f080..aaa576e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,27 +1,34 @@
{
- "manifest_version": 3,
- "name": "Credit Karma Transactions Exporter",
- "version": "1.2",
- "description": "Export transactions from Credit Karma within a specified date range.",
- "permissions": ["activeTab", "scripting"],
- "action": {
- "default_popup": "popup.html",
- "default_icon": {
- "16": "icon.png",
- "48": "icon.png",
- "128": "icon.png"
- }
- },
- "content_scripts": [
- {
- "matches": ["*://www.creditkarma.com/*"],
- "js": ["content.js"]
- }
- ],
- "background": {
- "service_worker": "background.js"
- },
- "content_security_policy": {
- "extension_pages": "script-src 'self'; object-src 'self'"
+ "manifest_version": 3,
+ "name": "Credit Karma Transactions Exporter",
+ "version": "2.0",
+ "description": "Export transactions from Credit Karma within a specified date range.",
+ "permissions": [
+ "activeTab",
+ "scripting"
+ ],
+ "action": {
+ "default_popup": "popup.html",
+ "default_icon": {
+ "16": "icon.png",
+ "48": "icon.png",
+ "128": "icon.png"
}
+ },
+ "content_scripts": [
+ {
+ "matches": [
+ "*://www.creditkarma.com/*"
+ ],
+ "js": [
+ "content.js"
+ ]
+ }
+ ],
+ "background": {
+ "service_worker": "background.js"
+ },
+ "content_security_policy": {
+ "extension_pages": "script-src 'self'; object-src 'self'"
+ }
}
\ No newline at end of file
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 31b1098..83cf36c 100644
--- a/popup.html
+++ b/popup.html
@@ -1,298 +1,118 @@
+