Kagi Billing Tracker v3.0.0

← Back to User Scripts

This version updates the script to work with the redesigned Kagi billing page. It searches all element types for the search count label, tries multiple label patterns for different plan types, and gracefully shows year progress only when the search count is not available (e.g. on Professional or Ultimate plans).

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