Kagi Billing Tracker v2.0.0
This version updates the script to work with the new Kagi billing page structure. It searches for the "Total searches this period" label and extracts the count from the page.
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
};
}
})();