Kagi Billing Progress Tracker latest version (currently v3.0.0)
← Back to User Scripts
Script Content
// ==UserScript==
// @name Kagi: Billing Progress Tracker
// @namespace https://www.timhilton.xyz/user-scripts
// @version 3.0.0
// @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;
// Labels to search for (in priority order) - covers different Kagi plan types
const SEARCH_COUNT_LABELS = [
"Searches",
"Total searches this period",
"Trial searches used",
];
console.debug(`${LOG_PREFIX} Script initialized`);
/**
* Get the usage text from the DOM by trying multiple known label patterns
* @returns {string|null} The raw usage text (e.g., "2,140/3600") or null if not found
*/
function getUsageTextFromDom() {
for (const labelText of SEARCH_COUNT_LABELS) {
const result = tryFindCountByLabel(labelText);
if (result !== null) {
return result;
}
}
console.debug(`${LOG_PREFIX} No search count label found on this page (plan may not track searches)`);
return null;
}
/**
* Try to find the search count value by looking for a specific label
* @param {string} labelText - Text of the label to look for
* @returns {string|null} The count text, or null if not found
*/
function tryFindCountByLabel(labelText) {
const searchLabelElement = findElementByText(labelText, 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) {
console.debug(`${LOG_PREFIX} No next sibling found after label "${labelText}"`);
return null;
}
const text = searchCountElement.textContent.trim();
// Must match n/total format (with or without comma formatting)
if (!/\d[\d,]*\/\d+/.test(text)) {
console.debug(`${LOG_PREFIX} Next sibling does not contain a count in n/total format`);
return null;
}
console.debug(`${LOG_PREFIX} Returning usage text: "${text}"`);
return text;
}
/**
* 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: 12px;
margin-bottom: 8px;
border-radius: 6px;
color: rgb(204 204 204);
font-size: 14px;
width: 100%;
box-sizing: border-box;
`;
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 above the renewal date row, at the billing content level
const renewalRow = renewalElement.parentNode;
renewalRow.parentNode.insertBefore(progressDiv, renewalRow);
console.log(`${LOG_PREFIX} 🎉 Progress info added to page successfully!`);
}
/**
* Display year progress only (when search count is not available on the current plan)
* @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 displayYearProgressOnly(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: 12px;
margin-bottom: 8px;
border-radius: 6px;
color: rgb(204 204 204);
font-size: 14px;
width: 100%;
box-sizing: border-box;
`;
progressDiv.innerHTML = `
Year progress: ${yearProgress.toFixed(1)}% (${elapsedDays}/${totalDays})
`;
const renewalRow = renewalElement.parentNode;
renewalRow.parentNode.insertBefore(progressDiv, renewalRow);
console.log(`${LOG_PREFIX} 🎉 Year progress info added to page (search count not available for this plan)`);
}
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 - may be null for plans without search limits
const usageText = getUsageTextFromDom();
if (!usageText) {
console.debug(`${LOG_PREFIX} Search count not available, showing year progress only`);
displayYearProgressOnly(yearProgress, elapsedDays, totalDaysInBillingYear, renewalElement);
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}`);
displayYearProgressOnly(yearProgress, elapsedDays, totalDaysInBillingYear, renewalElement);
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) {
// Search all element types (not limited to specific tags)
const elements = document.querySelectorAll('*');
console.debug(`${LOG_PREFIX} Searching ${elements.length} elements for text: "${text}"`);
for (let element of elements) {
// Skip elements with many children (likely container elements)
if (element.children.length > 5) continue;
const elementText = element.textContent.trim();
// Skip very long text (likely containers with combined child content)
if (elementText.length > 300) continue;
if (exact) {
if (elementText.startsWith(text)) {
console.debug(`${LOG_PREFIX} Found exact match element`);
return element;
}
} else {
if (elementText === 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
};
}
})();