Kagi Billing Progress Tracker latest version (currently v2.0.1)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Kagi: Billing Progress Tracker
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      2.0.1
// @description  Add progress percentages to Kagi billing page
// @author       Tim Hilton using GitHub Copilot
// @match        https://kagi.com/settings/billing
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[Kagi: Billing Progress Tracker]';
    const SEARCHES_PER_MONTH = 300;
    const SEARCHES_PER_YEAR = SEARCHES_PER_MONTH * 12;
    const MAX_RETRIES = 3;
    const RETRY_DELAY_MS = 1000;

    console.debug(`${LOG_PREFIX} Script initialized`);

    /**
     * Get the usage text from the DOM
     * @returns {string|null} The raw usage text (e.g., "2,140/3600") or null if not found
     */
    function getUsageTextFromDom() {
        // Find search count using known page structure
        const searchLabelElement = findElementByText("Total searches this period", false);
        if (!searchLabelElement) {
            console.debug(`${LOG_PREFIX} "Total searches this period" label not found`);
            return null;
        }
        
        console.debug(`${LOG_PREFIX} Found search label element`);
        
        // The count might be in the next sibling, or within the same parent
        let searchCountElement = searchLabelElement.nextElementSibling;
        
        // If no next sibling, try looking for child elements with numbers in the parent
        if (!searchCountElement) {
            console.debug(`${LOG_PREFIX} No next sibling found, searching in parent and children...`);
            const parent = searchLabelElement.parentElement;
            if (parent) {
                const children = Array.from(parent.querySelectorAll('*'));
                
                // Priority 1: Look for comma-formatted number with slash (e.g., "2,140/3600")
                for (const child of children) {
                    if (child !== searchLabelElement) {
                        const text = child.textContent.trim();
                        // Must have comma-separated number AND slash
                        if (/\d{1,3}(?:,\d{3})+\/\d+/.test(text)) {
                            searchCountElement = child;
                            console.debug(`${LOG_PREFIX} Found search count (comma+slash format)`);
                            break;
                        }
                    }
                }
                
                // Priority 2: Look for non-comma number with slash (e.g., "154/3600")
                // Require at least 2 digits before slash to avoid single digits like "5/3600"
                if (!searchCountElement) {
                    for (const child of children) {
                        if (child !== searchLabelElement) {
                            const text = child.textContent.trim();
                            // Match 2+ digits, slash, digits - anchored to avoid partial matches
                            if (/^\s*\d{2,}\/\d+\s*$/.test(text)) {
                                searchCountElement = child;
                                console.debug(`${LOG_PREFIX} Found search count (slash format)`);
                                break;
                            }
                        }
                    }
                }
                
                // Fallback: any element with numbers (last resort)
                if (!searchCountElement) {
                    for (const child of children) {
                        if (child !== searchLabelElement && /\d/.test(child.textContent)) {
                            searchCountElement = child;
                            console.debug(`${LOG_PREFIX} Found search count (fallback: any number)`);
                            break;
                        }
                    }
                }
            }
        }
        
        if (!searchCountElement) {
            console.debug(`${LOG_PREFIX} Search count element not found`);
            return null;
        }

        console.debug(`${LOG_PREFIX} Returning usage text: "${searchCountElement.textContent.trim()}"`);
        return searchCountElement.textContent.trim();
    }

    /**
     * Parse usage text to extract the number of searches
     * @param {string} usageText - Raw usage text (e.g., "2,140/3600" or "154/3600")
     * @returns {number|null} The number of searches as an integer, or null if parsing fails
     */
    function parseUsageText(usageText) {
        if (!usageText) {
            return null;
        }

        // Extract only the first number from the text
        // Priority 1: Try comma-formatted number (e.g., "2,140")
        let searchCountMatch = usageText.match(/\d{1,3}(?:,\d{3})+/);
        
        // Priority 2: If no comma format, try any sequence of digits
        if (!searchCountMatch) {
            searchCountMatch = usageText.match(/\d+/);
        }
        
        if (!searchCountMatch) {
            console.debug(`${LOG_PREFIX} Could not extract search count from: ${usageText}`);
            return null;
        }
        
        const searchCount = parseInt(searchCountMatch[0].replace(/,/g, ''));
        console.debug(`${LOG_PREFIX} Parsed search count: ${searchCount} from text: ${usageText}`);
        return searchCount;
    }

    /**
     * Calculate usage statistics
     * @param {number} searchCount - Number of searches used
     * @param {number} yearProgress - Percentage of billing year elapsed (0-100)
     * @returns {Object} Statistics including searchProgress, searchRate, expectedSearches, difference, differenceText
     */
    function calculateUsageStats(searchCount, yearProgress) {
        const searchProgress = Math.min(100, (searchCount / SEARCHES_PER_YEAR) * 100);
        const expectedSearches = (yearProgress / 100) * SEARCHES_PER_YEAR;
        const searchRate = expectedSearches > 0 ? (searchCount / expectedSearches) * 100 : 0;
        const difference = searchCount - expectedSearches;
        const differenceText = difference >= 0
            ? `${Math.round(difference)} above expected`
            : `${Math.round(Math.abs(difference))} below expected`;

        return {
            searchProgress,
            searchRate,
            expectedSearches,
            difference,
            differenceText
        };
    }

    /**
     * Display usage stats in the DOM
     * @param {Object} stats - Statistics object from calculateUsageStats
     * @param {number} searchCount - Number of searches used
     * @param {number} yearProgress - Percentage of billing year elapsed
     * @param {number} elapsedDays - Number of days elapsed in billing period
     * @param {number} totalDays - Total days in billing period
     * @param {Element} renewalElement - DOM element to insert the stats after
     */
    function displayUsageStats(stats, searchCount, yearProgress, elapsedDays, totalDays, renewalElement) {
        const progressDiv = document.createElement('div');
        progressDiv.style.cssText = `
            border: 1px solid rgb(163 158 52);
            background-color: rgb(57 58 17);
            padding: 12px;
            margin-top: 8px;
            border-radius: 6px;
            color: rgb(204 204 204);
            font-size: 14px;
        `;
        progressDiv.innerHTML = `
            
Year progress: ${yearProgress.toFixed(1)}% (${elapsedDays}/${totalDays})
Search progress: ${stats.searchProgress.toFixed(1)}% (${searchCount}/${SEARCHES_PER_YEAR})
Search rate: ${stats.searchRate.toFixed(1)}% of expected usage (${stats.differenceText})
`; // Insert after the renewal date element renewalElement.parentNode.insertBefore(progressDiv, renewalElement.nextSibling); console.log(`${LOG_PREFIX} 🎉 Progress info added to page successfully!`); } function addProgressInfo(retryCount = 0) { console.debug(`${LOG_PREFIX} addProgressInfo() called, attempt ${retryCount + 1}`); // Find the renewal date element const renewalElement = findElementByText("Next renewal is", true); if (!renewalElement) { if (retryCount < MAX_RETRIES) { console.debug(`${LOG_PREFIX} Renewal element not found, retrying in ${RETRY_DELAY_MS}ms...`); setTimeout(() => addProgressInfo(retryCount + 1), RETRY_DELAY_MS); } else { console.log(`${LOG_PREFIX} ❌ Renewal element not found after ${MAX_RETRIES} attempts`); } return; } console.debug(`${LOG_PREFIX} Found renewal element`); // Extract renewal date const renewalText = renewalElement.textContent; const renewalMatch = renewalText.match(/Next renewal is (\d{4}-\d{2}-\d{2})/); if (!renewalMatch) { console.log(`${LOG_PREFIX} ❌ Could not parse renewal date from: ${renewalText}`); return; } console.debug(`${LOG_PREFIX} Parsed renewal date: ${renewalMatch[1]}`); const renewalDate = new Date(renewalMatch[1]); const currentDate = new Date(); const yearAgo = new Date(renewalDate); yearAgo.setFullYear(yearAgo.getFullYear() - 1); // Calculate time-based progress const totalYearMs = renewalDate.getTime() - yearAgo.getTime(); const elapsedYearMs = currentDate.getTime() - yearAgo.getTime(); const yearProgress = Math.min(100, Math.max(0, (elapsedYearMs / totalYearMs) * 100)); const MS_PER_DAY = 1000 * 60 * 60 * 24; const totalDaysInBillingYear = Math.round(totalYearMs / MS_PER_DAY); const elapsedDays = Math.floor(elapsedYearMs / MS_PER_DAY); console.debug(`${LOG_PREFIX} Year progress: ${yearProgress.toFixed(1)}%`); // Get usage text from DOM const usageText = getUsageTextFromDom(); if (!usageText) { console.log(`${LOG_PREFIX} ❌ Could not get usage text from DOM`); return; } // Parse usage text to get search count const searchCount = parseUsageText(usageText); if (searchCount === null) { console.log(`${LOG_PREFIX} ❌ Could not parse usage text: ${usageText}`); return; } // Calculate usage statistics const stats = calculateUsageStats(searchCount, yearProgress); console.debug(`${LOG_PREFIX} Usage stats calculated: searchProgress=${stats.searchProgress.toFixed(1)}%, searchRate=${stats.searchRate.toFixed(1)}%`); // Display the statistics displayUsageStats(stats, searchCount, yearProgress, elapsedDays, totalDaysInBillingYear, renewalElement); } // Helper function since :contains() doesn't work in querySelector function findElementByText(text, exact = false) { const elements = document.querySelectorAll('span, div, td, p, label'); console.debug(`${LOG_PREFIX} Searching ${elements.length} elements for text: "${text}"`); for (let element of elements) { const elementText = element.textContent.trim(); if (exact) { if (elementText.startsWith(text)) { console.debug(`${LOG_PREFIX} Found exact match element`); return element; } } else { if (elementText.includes(text)) { console.debug(`${LOG_PREFIX} Found element containing text`); return element; } } } console.debug(`${LOG_PREFIX} No element found containing text: "${text}"`); return null; } // Wait for page to load and try to add progress info // Only run in browser environment (not during Node.js testing) if (typeof document !== 'undefined') { console.debug(`${LOG_PREFIX} Document ready state: ${document.readyState}`); if (document.readyState === 'loading') { console.debug(`${LOG_PREFIX} Document still loading, waiting for DOMContentLoaded`); document.addEventListener('DOMContentLoaded', addProgressInfo); } else { console.debug(`${LOG_PREFIX} Document already loaded, calling addProgressInfo`); addProgressInfo(); } } // Export for Node.js (CommonJS) - Required for Jest unit tests // Jest runs in Node.js environment and uses CommonJS require/module.exports if (typeof module !== 'undefined' && module.exports) { module.exports = { parseUsageText, calculateUsageStats, SEARCHES_PER_YEAR }; } })();