Kagi Progress Tracker for Home/Search latest version (currently v1.3.0)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Kagi: Usage Summary for Home/Search
// @namespace    https://github.com/tjhleeds/user-scripts/
// @version      1.3.0
// @description  Display Kagi billing progress on home and search pages
// @author       tjhleeds
// @match        https://kagi.com/
// @match        https://kagi.com/search*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    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';

    let progressOverlay = null;

    function getCachedData() {
        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) {
                    return JSON.parse(data);
                }
            }
        } catch (error) {
            console.error('Error reading cached data:', error);
        }
        return null;
    }

    function setCachedData(data) {
        try {
            localStorage.setItem(CACHE_KEY, JSON.stringify(data));
            localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
        } catch (error) {
            console.error('Error caching data:', error);
        }
    }

    async function fetchBillingData() {
        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
            const renewalElement = Array.from(doc.querySelectorAll('span, div'))
                .find(el => el.textContent.includes('Next renewal is'));

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

            // Find search count
            const searchCountElement = doc.querySelector('.billing_box_count_num_1');
            if (!searchCountElement) {
                throw new Error('Search count element not found');
            }

            const searchCount = parseInt(searchCountElement.textContent.trim().replace(/,/g, ''));
            const renewalDate = new Date(renewalMatch[1]);

            return { searchCount, renewalDate };

        } catch (error) {
            console.error('Error fetching billing data:', error);
            throw error;
        }
    }

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

        // 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) {
        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';

        const difference = data.difference;
        const differenceText = difference >= 0
            ? `${Math.round(difference)} above expected`
            : `${Math.round(Math.abs(difference))} below expected`;

        contentDiv.innerHTML = `
            
Year progress: ${data.yearProgress.toFixed(1)}% (${data.elapsedDays}/${data.totalDaysInBillingYear})
Search progress: ${data.searchProgress.toFixed(1)}% (${data.searchCount}/${SEARCHES_PER_YEAR})
Search rate: ${data.searchRate.toFixed(1)}% of expected usage (${differenceText})
`; progressOverlay.appendChild(closeButton); progressOverlay.appendChild(contentDiv); document.body.appendChild(progressOverlay); } async function showProgressInfo() { try { // Try cached data first let cachedData = getCachedData(); if (cachedData && cachedData.hasOwnProperty('difference')) { createProgressOverlay(cachedData); return; } // Fetch fresh data const billingData = await fetchBillingData(); const progressData = calculateProgress(billingData.searchCount, billingData.renewalDate); setCachedData(progressData); createProgressOverlay(progressData); } catch (error) { console.error('Error showing progress info:', error); } } // Initialize when page loads if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', showProgressInfo); } else { showProgressInfo(); } })();