Kagi Billing Progress Tracker latest version (currently v2.0.1)
← Back to User Scripts
Script Content
// ==UserScript==
// @name Kagi: Billing Progress Tracker
// @namespace https://www.timhilton.xyz/user-scripts
// @version 2.0.1
// @description Add progress percentages to Kagi billing page
// @author Tim Hilton using GitHub Copilot
// @match https://kagi.com/settings/billing
// @grant none
// ==/UserScript==
(function() {
'use strict';
const LOG_PREFIX = '[Kagi: Billing Progress Tracker]';
const SEARCHES_PER_MONTH = 300;
const SEARCHES_PER_YEAR = SEARCHES_PER_MONTH * 12;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;
console.debug(`${LOG_PREFIX} Script initialized`);
/**
* Get the usage text from the DOM
* @returns {string|null} The raw usage text (e.g., "2,140/3600") or null if not found
*/
function getUsageTextFromDom() {
// Find search count using known page structure
const searchLabelElement = findElementByText("Total searches this period", false);
if (!searchLabelElement) {
console.debug(`${LOG_PREFIX} "Total searches this period" label not found`);
return null;
}
console.debug(`${LOG_PREFIX} Found search label element`);
// The count might be in the next sibling, or within the same parent
let searchCountElement = searchLabelElement.nextElementSibling;
// If no next sibling, try looking for child elements with numbers in the parent
if (!searchCountElement) {
console.debug(`${LOG_PREFIX} No next sibling found, searching in parent and children...`);
const parent = searchLabelElement.parentElement;
if (parent) {
const children = Array.from(parent.querySelectorAll('*'));
// Priority 1: Look for comma-formatted number with slash (e.g., "2,140/3600")
for (const child of children) {
if (child !== searchLabelElement) {
const text = child.textContent.trim();
// Must have comma-separated number AND slash
if (/\d{1,3}(?:,\d{3})+\/\d+/.test(text)) {
searchCountElement = child;
console.debug(`${LOG_PREFIX} Found search count (comma+slash format)`);
break;
}
}
}
// Priority 2: Look for non-comma number with slash (e.g., "154/3600")
// Require at least 2 digits before slash to avoid single digits like "5/3600"
if (!searchCountElement) {
for (const child of children) {
if (child !== searchLabelElement) {
const text = child.textContent.trim();
// Match 2+ digits, slash, digits - anchored to avoid partial matches
if (/^\s*\d{2,}\/\d+\s*$/.test(text)) {
searchCountElement = child;
console.debug(`${LOG_PREFIX} Found search count (slash format)`);
break;
}
}
}
}
// Fallback: any element with numbers (last resort)
if (!searchCountElement) {
for (const child of children) {
if (child !== searchLabelElement && /\d/.test(child.textContent)) {
searchCountElement = child;
console.debug(`${LOG_PREFIX} Found search count (fallback: any number)`);
break;
}
}
}
}
}
if (!searchCountElement) {
console.debug(`${LOG_PREFIX} Search count element not found`);
return null;
}
console.debug(`${LOG_PREFIX} Returning usage text: "${searchCountElement.textContent.trim()}"`);
return searchCountElement.textContent.trim();
}
/**
* Parse usage text to extract the number of searches
* @param {string} usageText - Raw usage text (e.g., "2,140/3600" or "154/3600")
* @returns {number|null} The number of searches as an integer, or null if parsing fails
*/
function parseUsageText(usageText) {
if (!usageText) {
return null;
}
// Extract only the first number from the text
// Priority 1: Try comma-formatted number (e.g., "2,140")
let searchCountMatch = usageText.match(/\d{1,3}(?:,\d{3})+/);
// Priority 2: If no comma format, try any sequence of digits
if (!searchCountMatch) {
searchCountMatch = usageText.match(/\d+/);
}
if (!searchCountMatch) {
console.debug(`${LOG_PREFIX} Could not extract search count from: ${usageText}`);
return null;
}
const searchCount = parseInt(searchCountMatch[0].replace(/,/g, ''));
console.debug(`${LOG_PREFIX} Parsed search count: ${searchCount} from text: ${usageText}`);
return searchCount;
}
/**
* Calculate usage statistics
* @param {number} searchCount - Number of searches used
* @param {number} yearProgress - Percentage of billing year elapsed (0-100)
* @returns {Object} Statistics including searchProgress, searchRate, expectedSearches, difference, differenceText
*/
function calculateUsageStats(searchCount, yearProgress) {
const searchProgress = Math.min(100, (searchCount / SEARCHES_PER_YEAR) * 100);
const expectedSearches = (yearProgress / 100) * SEARCHES_PER_YEAR;
const searchRate = expectedSearches > 0 ? (searchCount / expectedSearches) * 100 : 0;
const difference = searchCount - expectedSearches;
const differenceText = difference >= 0
? `${Math.round(difference)} above expected`
: `${Math.round(Math.abs(difference))} below expected`;
return {
searchProgress,
searchRate,
expectedSearches,
difference,
differenceText
};
}
/**
* Display usage stats in the DOM
* @param {Object} stats - Statistics object from calculateUsageStats
* @param {number} searchCount - Number of searches used
* @param {number} yearProgress - Percentage of billing year elapsed
* @param {number} elapsedDays - Number of days elapsed in billing period
* @param {number} totalDays - Total days in billing period
* @param {Element} renewalElement - DOM element to insert the stats after
*/
function displayUsageStats(stats, searchCount, yearProgress, elapsedDays, totalDays, renewalElement) {
const progressDiv = document.createElement('div');
progressDiv.style.cssText = `
border: 1px solid rgb(163 158 52);
background-color: rgb(57 58 17);
padding: 12px;
margin-top: 8px;
border-radius: 6px;
color: rgb(204 204 204);
font-size: 14px;
`;
progressDiv.innerHTML = `
Year progress: ${yearProgress.toFixed(1)}% (${elapsedDays}/${totalDays})
Search progress: ${stats.searchProgress.toFixed(1)}% (${searchCount}/${SEARCHES_PER_YEAR})
Search rate: ${stats.searchRate.toFixed(1)}% of expected usage (${stats.differenceText})
`;
// Insert after the renewal date element
renewalElement.parentNode.insertBefore(progressDiv, renewalElement.nextSibling);
console.log(`${LOG_PREFIX} 🎉 Progress info added to page successfully!`);
}
function addProgressInfo(retryCount = 0) {
console.debug(`${LOG_PREFIX} addProgressInfo() called, attempt ${retryCount + 1}`);
// Find the renewal date element
const renewalElement = findElementByText("Next renewal is", true);
if (!renewalElement) {
if (retryCount < MAX_RETRIES) {
console.debug(`${LOG_PREFIX} Renewal element not found, retrying in ${RETRY_DELAY_MS}ms...`);
setTimeout(() => addProgressInfo(retryCount + 1), RETRY_DELAY_MS);
} else {
console.log(`${LOG_PREFIX} ❌ Renewal element not found after ${MAX_RETRIES} attempts`);
}
return;
}
console.debug(`${LOG_PREFIX} Found renewal element`);
// Extract renewal date
const renewalText = renewalElement.textContent;
const renewalMatch = renewalText.match(/Next renewal is (\d{4}-\d{2}-\d{2})/);
if (!renewalMatch) {
console.log(`${LOG_PREFIX} ❌ Could not parse renewal date from: ${renewalText}`);
return;
}
console.debug(`${LOG_PREFIX} Parsed renewal date: ${renewalMatch[1]}`);
const renewalDate = new Date(renewalMatch[1]);
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);
console.debug(`${LOG_PREFIX} Year progress: ${yearProgress.toFixed(1)}%`);
// Get usage text from DOM
const usageText = getUsageTextFromDom();
if (!usageText) {
console.log(`${LOG_PREFIX} ❌ Could not get usage text from DOM`);
return;
}
// Parse usage text to get search count
const searchCount = parseUsageText(usageText);
if (searchCount === null) {
console.log(`${LOG_PREFIX} ❌ Could not parse usage text: ${usageText}`);
return;
}
// Calculate usage statistics
const stats = calculateUsageStats(searchCount, yearProgress);
console.debug(`${LOG_PREFIX} Usage stats calculated: searchProgress=${stats.searchProgress.toFixed(1)}%, searchRate=${stats.searchRate.toFixed(1)}%`);
// Display the statistics
displayUsageStats(stats, searchCount, yearProgress, elapsedDays, totalDaysInBillingYear, renewalElement);
}
// Helper function since :contains() doesn't work in querySelector
function findElementByText(text, exact = false) {
const elements = document.querySelectorAll('span, div, td, p, label');
console.debug(`${LOG_PREFIX} Searching ${elements.length} elements for text: "${text}"`);
for (let element of elements) {
const elementText = element.textContent.trim();
if (exact) {
if (elementText.startsWith(text)) {
console.debug(`${LOG_PREFIX} Found exact match element`);
return element;
}
} else {
if (elementText.includes(text)) {
console.debug(`${LOG_PREFIX} Found element containing text`);
return element;
}
}
}
console.debug(`${LOG_PREFIX} No element found containing text: "${text}"`);
return null;
}
// Wait for page to load and try to add progress info
// Only run in browser environment (not during Node.js testing)
if (typeof document !== 'undefined') {
console.debug(`${LOG_PREFIX} Document ready state: ${document.readyState}`);
if (document.readyState === 'loading') {
console.debug(`${LOG_PREFIX} Document still loading, waiting for DOMContentLoaded`);
document.addEventListener('DOMContentLoaded', addProgressInfo);
} else {
console.debug(`${LOG_PREFIX} Document already loaded, calling addProgressInfo`);
addProgressInfo();
}
}
// Export for Node.js (CommonJS) - Required for Jest unit tests
// Jest runs in Node.js environment and uses CommonJS require/module.exports
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
parseUsageText,
calculateUsageStats,
SEARCHES_PER_YEAR
};
}
})();