Kagi Usage Summary Search Page v2.0.0
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();
}
})();