Kagi Billing Progress Tracker latest version (currently v3.0.0)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Kagi: Billing Progress Tracker
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      3.0.0
// @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;

    // Labels to search for (in priority order) - covers different Kagi plan types
    const SEARCH_COUNT_LABELS = [
        "Searches",
        "Total searches this period",
        "Trial searches used",
    ];

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

    /**
     * Get the usage text from the DOM by trying multiple known label patterns
     * @returns {string|null} The raw usage text (e.g., "2,140/3600") or null if not found
     */
    function getUsageTextFromDom() {
        for (const labelText of SEARCH_COUNT_LABELS) {
            const result = tryFindCountByLabel(labelText);
            if (result !== null) {
                return result;
            }
        }

        console.debug(`${LOG_PREFIX} No search count label found on this page (plan may not track searches)`);
        return null;
    }

    /**
     * Try to find the search count value by looking for a specific label
     * @param {string} labelText - Text of the label to look for
     * @returns {string|null} The count text, or null if not found
     */
    function tryFindCountByLabel(labelText) {
        const searchLabelElement = findElementByText(labelText, false);
        if (!searchLabelElement) {
            return null;
        }

        console.debug(`${LOG_PREFIX} Found search label element for: "${labelText}"`);

        // The count is in the next sibling element
        const searchCountElement = searchLabelElement.nextElementSibling;

        if (!searchCountElement) {
            console.debug(`${LOG_PREFIX} No next sibling found after label "${labelText}"`);
            return null;
        }

        const text = searchCountElement.textContent.trim();
        // Must match n/total format (with or without comma formatting)
        if (!/\d[\d,]*\/\d+/.test(text)) {
            console.debug(`${LOG_PREFIX} Next sibling does not contain a count in n/total format`);
            return null;
        }

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

    /**
     * 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: 12px;
            margin-bottom: 8px;
            border-radius: 6px;
            color: rgb(204 204 204);
            font-size: 14px;
            width: 100%;
            box-sizing: border-box;
        `;
        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 above the renewal date row, at the billing content level const renewalRow = renewalElement.parentNode; renewalRow.parentNode.insertBefore(progressDiv, renewalRow); console.log(`${LOG_PREFIX} 🎉 Progress info added to page successfully!`); } /** * Display year progress only (when search count is not available on the current plan) * @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 displayYearProgressOnly(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: 12px; margin-bottom: 8px; border-radius: 6px; color: rgb(204 204 204); font-size: 14px; width: 100%; box-sizing: border-box; `; progressDiv.innerHTML = `
Year progress: ${yearProgress.toFixed(1)}% (${elapsedDays}/${totalDays})
`; const renewalRow = renewalElement.parentNode; renewalRow.parentNode.insertBefore(progressDiv, renewalRow); console.log(`${LOG_PREFIX} 🎉 Year progress info added to page (search count not available for this plan)`); } 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 - may be null for plans without search limits const usageText = getUsageTextFromDom(); if (!usageText) { console.debug(`${LOG_PREFIX} Search count not available, showing year progress only`); displayYearProgressOnly(yearProgress, elapsedDays, totalDaysInBillingYear, renewalElement); 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}`); displayYearProgressOnly(yearProgress, elapsedDays, totalDaysInBillingYear, renewalElement); 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) { // Search all element types (not limited to specific tags) const elements = document.querySelectorAll('*'); console.debug(`${LOG_PREFIX} Searching ${elements.length} elements for text: "${text}"`); for (let element of elements) { // Skip elements with many children (likely container elements) if (element.children.length > 5) continue; const elementText = element.textContent.trim(); // Skip very long text (likely containers with combined child content) if (elementText.length > 300) continue; if (exact) { if (elementText.startsWith(text)) { console.debug(`${LOG_PREFIX} Found exact match element`); return element; } } else { if (elementText === 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 }; } })();