Kagi Billing Tracker v2.0.0

← Back to User Scripts

This version updates the script to work with the new Kagi billing page structure. It searches for the "Total searches this period" label and extracts the count from the page.

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 }; } })();