Kagi Progress Tracker for Home/Search latest version (currently v2.0.0)

← Back to User Scripts

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