Kagi Usage Summary Search Page v2.0.0

← Back to User Scripts

This version updates the script to work with the redesigned Kagi billing page. It replaces the class-based search count selector with a text-based search using multiple label patterns for different plan types. The overlay now shows year progress only when search count is not available (e.g. on Professional or Ultimate plans).

Script Content

// ==UserScript==
// @name         Kagi: Usage Summary for Home/Search
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      2.0.0
// @description  Display Kagi billing progress on home and search pages
// @author       Tim Hilton
// @match        https://kagi.com/
// @match        https://kagi.com/search*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[Kagi: Usage Summary]';
    const SEARCHES_PER_MONTH = 300;
    const SEARCHES_PER_YEAR = SEARCHES_PER_MONTH * 12;
    const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
    const CACHE_KEY = 'kagi_progress_data';
    const CACHE_TIMESTAMP_KEY = 'kagi_progress_timestamp';

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

    let progressOverlay = null;

    function getCachedData() {
        console.debug(`${LOG_PREFIX} Checking for cached data`);
        
        try {
            const timestamp = localStorage.getItem(CACHE_TIMESTAMP_KEY);
            const data = localStorage.getItem(CACHE_KEY);

            if (timestamp && data) {
                const age = Date.now() - parseInt(timestamp);
                if (age < CACHE_DURATION_MS) {
                    console.debug(`${LOG_PREFIX} Using cached data (age: ${Math.round(age / 1000)}s)`);
                    return JSON.parse(data);
                }
                console.debug(`${LOG_PREFIX} Cache expired (age: ${Math.round(age / 1000)}s)`);
            }
        } catch (error) {
            console.log(`${LOG_PREFIX} ❌ Error reading cached data: ${error}`);
        }
        return null;
    }

    function setCachedData(data) {
        console.debug(`${LOG_PREFIX} Caching data`);
        
        try {
            localStorage.setItem(CACHE_KEY, JSON.stringify(data));
            localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
        } catch (error) {
            console.log(`${LOG_PREFIX} ❌ Error caching data: ${error}`);
        }
    }

    async function fetchBillingData() {
        console.debug(`${LOG_PREFIX} Fetching billing data`);
        
        try {
            const response = await fetch('https://kagi.com/settings/billing', {
                method: 'GET',
                credentials: 'same-origin',
                headers: {
                    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
                }
            });

            if (!response.ok) {
                throw new Error(`Request failed with status: ${response.status}`);
            }

            const html = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');

            // Find renewal date - search all elements (startsWith match, so exact=true)
            const renewalElement = findElementInDoc('Next renewal is', doc, true);

            if (!renewalElement) {
                throw new Error('Renewal element not found');
            }

            const renewalMatch = renewalElement.textContent.match(/Next renewal is (\d{4}-\d{2}-\d{2})/);
            if (!renewalMatch) {
                throw new Error('Could not parse renewal date');
            }

            const renewalDate = new Date(renewalMatch[1]);

            // Find search count using text-based search - may be null for plans without search limits
            const searchCount = findSearchCountInDoc(doc);

            if (searchCount !== null) {
                console.debug(`${LOG_PREFIX} Fetched billing data: ${searchCount} searches, renewal: ${renewalDate.toDateString()}`);
            } else {
                console.debug(`${LOG_PREFIX} Fetched billing data: no search count (plan may not track searches), renewal: ${renewalDate.toDateString()}`);
            }

            return { searchCount, renewalDate };

        } catch (error) {
            console.log(`${LOG_PREFIX} ❌ Error fetching billing data: ${error}`);
            throw error;
        }
    }

    /**
     * Find the search count in a parsed billing page document
     * @param {Document} doc - The parsed document to search in
     * @returns {number|null} The search count, or null if not found
     */
    function findSearchCountInDoc(doc) {
        for (const labelText of SEARCH_COUNT_LABELS) {
            const count = tryFindCountByLabelInDoc(labelText, doc);
            if (count !== null) {
                return count;
            }
        }
        console.debug(`${LOG_PREFIX} No search count label found in billing page (plan may not track searches)`);
        return null;
    }

    /**
     * Try to find search count by a specific label in a document
     * @param {string} labelText - The label text to search for
     * @param {Document} doc - The document to search in
     * @returns {number|null} The parsed search count, or null if not found
     */
    function tryFindCountByLabelInDoc(labelText, doc) {
        const searchLabelElement = findElementInDoc(labelText, doc, 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) {
            return null;
        }

        const rawText = searchCountElement.textContent.trim();
        // Must match n/total format (with or without comma formatting)
        if (!/\d[\d,]*\/\d+/.test(rawText)) {
            return null;
        }

        let match = rawText.match(/\d{1,3}(?:,\d{3})+/);
        if (!match) {
            match = rawText.match(/\d+/);
        }
        if (!match) {
            return null;
        }

        return parseInt(match[0].replace(/,/g, ''));
    }

    /**
     * Find an element by text content in a given document, searching all element types
     * @param {string} text - The text to search for
     * @param {Document} doc - The document to search in
     * @param {boolean} exact - If true, element text must start with the search text
     * @returns {Element|null} The matching element, or null if not found
     */
    function findElementInDoc(text, doc, exact = false) {
        const elements = doc.querySelectorAll('*');
        for (let element of elements) {
            if (element.children.length > 5) continue;
            const elementText = element.textContent.trim();
            if (elementText.length > 300) continue;
            if (exact) {
                if (elementText.startsWith(text)) return element;
            } else {
                if (elementText === text) return element;
            }
        }
        return null;
    }

    function calculateProgress(searchCount, renewalDate) {
        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);

        // Search progress stats are only available when searchCount is known
        if (searchCount === null) {
            return { yearProgress, searchProgress: null, searchRate: null, searchCount: null, expectedSearches: null, difference: null, elapsedDays, totalDaysInBillingYear };
        }

        // Calculate expected searches based on year progress
        const expectedSearches = (yearProgress / 100) * SEARCHES_PER_YEAR;

        // Calculate search progress
        const searchProgress = Math.min(100, (searchCount / SEARCHES_PER_YEAR) * 100);

        // Calculate search usage rate
        const searchRate = expectedSearches > 0 ? (searchCount / expectedSearches) * 100 : 0;

        const difference = searchCount - expectedSearches;

        return { yearProgress, searchProgress, searchRate, searchCount, expectedSearches, difference, elapsedDays, totalDaysInBillingYear };
    }

    function createProgressOverlay(data) {
        console.debug(`${LOG_PREFIX} Creating progress overlay`);
        
        if (progressOverlay) {
            progressOverlay.remove();
        }

        progressOverlay = document.createElement('div');
        progressOverlay.style.cssText = `
            position: fixed;
            top: 20px;
            left: 20px;
            z-index: 10000;
            border: 1px solid rgb(163 158 52);
            background-color: rgb(57 58 17);
            padding: 12px;
            border-radius: 6px;
            color: rgb(204 204 204);
            font-size: 14px;
            font-family: system-ui, -apple-system, sans-serif;
            max-width: 300px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
            transition: opacity 0.5s ease-in-out;
        `;

        const creationTime = Date.now();
        let fadeTimeoutId = null;

        const fadeFn = () => {
            progressOverlay.style.opacity = '0.2';
        };

        fadeTimeoutId = setTimeout(fadeFn, 3000);

        progressOverlay.addEventListener('mouseenter', () => {
            clearTimeout(fadeTimeoutId);
            progressOverlay.style.opacity = '1';
        });

        progressOverlay.addEventListener('mouseleave', () => {
            const elapsed = Date.now() - creationTime;
            if (elapsed >= 3000) {
                progressOverlay.style.opacity = '0.2';
            } else {
                fadeTimeoutId = setTimeout(fadeFn, 3000 - elapsed);
            }
        });

        const closeButton = document.createElement('button');
        closeButton.innerHTML = '×';
        closeButton.style.cssText = `
            position: absolute;
            top: 4px;
            right: 8px;
            background: none;
            border: none;
            color: rgb(204 204 204);
            font-size: 18px;
            cursor: pointer;
            padding: 0;
            width: 20px;
            height: 20px;
            display: flex;
            align-items: center;
            justify-content: center;
        `;
        closeButton.onclick = () => progressOverlay.remove();

        const contentDiv = document.createElement('div');
        contentDiv.style.paddingRight = '20px';

        let contentHtml = `
Year progress: ${data.yearProgress.toFixed(1)}% (${data.elapsedDays}/${data.totalDaysInBillingYear})
`; if (data.searchCount !== null) { const difference = data.difference; const differenceText = difference >= 0 ? `${Math.round(difference)} above expected` : `${Math.round(Math.abs(difference))} below expected`; contentHtml += `
Search progress: ${data.searchProgress.toFixed(1)}% (${data.searchCount}/${SEARCHES_PER_YEAR})
Search rate: ${data.searchRate.toFixed(1)}% of expected usage (${differenceText})
`; } contentDiv.innerHTML = contentHtml; progressOverlay.appendChild(closeButton); progressOverlay.appendChild(contentDiv); document.body.appendChild(progressOverlay); } async function showProgressInfo() { console.debug(`${LOG_PREFIX} Initializing script`); try { // Try cached data first let cachedData = getCachedData(); if (cachedData && Object.hasOwn(cachedData, 'yearProgress')) { console.log(`${LOG_PREFIX} ✅ Displaying cached progress data`); createProgressOverlay(cachedData); return; } // Fetch fresh data console.debug(`${LOG_PREFIX} No valid cache, fetching fresh data`); const billingData = await fetchBillingData(); const progressData = calculateProgress(billingData.searchCount, billingData.renewalDate); setCachedData(progressData); createProgressOverlay(progressData); console.log(`${LOG_PREFIX} 🎉 Progress data displayed successfully`); } catch (error) { console.log(`${LOG_PREFIX} ❌ Error showing progress info: ${error}`); } } // Initialize when page loads if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', showProgressInfo); } else { showProgressInfo(); } })();