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