Linux.do Credit Display

显示 linux.do 今日积分变化;如果 Safari 取不到积分,可先去 credit.linux.do 同步一次;每半小时自动刷新,双击清除缓存

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Linux.do Credit Display
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  显示 linux.do 今日积分变化;如果 Safari 取不到积分,可先去 credit.linux.do 同步一次;每半小时自动刷新,双击清除缓存
// @author       qppq54s
// @match        https://linux.do/*
// @match        https://credit.linux.do/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      credit.linux.do
// ==/UserScript==

(function() {
    'use strict';

    // API 和页面配置
    const TRANSACTIONS_API = 'https://credit.linux.do/api/v1/order/transactions';
    const LEADERBOARD_URL = 'https://linux.do/leaderboard';

    // 缓存 key
    const STORAGE_KEY = 'linux_do_credit_cache';
    const CURRENT_SCORE_KEY = 'linux_do_current_score_cache';
    const POSITION_KEY = 'linux_do_credit_position';

    // 请求配置
    const PAGE_SIZE = 20;
    const IFRAME_TIMEOUT = 15000;
    const ELEMENT_WAIT_TIMEOUT = 5000;
    const AUTO_REFRESH_INTERVAL = 30 * 60 * 1000;

    // DOM 选择器
    const SELECTORS = {
        USER_SCORE: '.user.-self .user__score',
        NUMBER_TITLE: '.number[title]'
    };

    // 延迟时间
    const DELAYS = {
        CLICK_DEBOUNCE: 250,
        IFRAME_INITIAL_WAIT: 500
    };

    // 通用容器样式
    const CONTAINER_STYLE = `
        position: fixed;
        bottom: 20px;
        right: 20px;
        background: #fff;
        color: #333;
        padding: 10px 14px;
        border-radius: 8px;
        font-size: 14px;
        z-index: 9999;
        box-shadow: 0 2px 12px rgba(0,0,0,0.15);
        white-space: nowrap;
        cursor: pointer;
    `;

    // 格式化日期为 YYYY-MM-DD
    function formatDate(date) {
        const y = date.getFullYear();
        const m = String(date.getMonth() + 1).padStart(2, '0');
        const d = String(date.getDate()).padStart(2, '0');
        return `${y}-${m}-${d}`;
    }

    // 获取本地时区偏移字符串(如 "+08:00")
    function getTimezoneOffset() {
        const offset = -new Date().getTimezoneOffset();
        const sign = offset >= 0 ? '+' : '-';
        const abs = Math.abs(offset);
        const h = String(Math.floor(abs / 60)).padStart(2, '0');
        const m = String(abs % 60).padStart(2, '0');
        return `${sign}${h}:${m}`;
    }

    // 获取时间范围(一周前到两日后)
    function getTodayRange() {
        const now = Date.now();
        const tz = getTimezoneOffset();
        const start = formatDate(new Date(now - 7 * 86400000));
        const end = formatDate(new Date(now + 2 * 86400000));
        return {
            startTime: `${start}T00:00:00${tz}`,
            endTime: `${end}T23:59:59${tz}`
        };
    }

    // 从 remark 解析积分: "社区积分从 1877 更新到 1938,变化 61" -> 1938
    function parseScore(remark) {
        const match = remark.match(/更新到\s*(\d+)/);
        if (!match) return null;
        const score = parseInt(match[1], 10);
        return isNaN(score) ? null : score;
    }

    // 安全解析数字文本(移除逗号,如 "2,003" -> 2003)
    function parseNumberText(text) {
        if (!text) return null;
        const num = parseInt(text.trim().replace(/,/g, ''), 10);
        return isNaN(num) ? null : num;
    }

    // 解析紧凑积分文本(如 "2.1k" -> 2100, "2k" -> 2000)
    function parseCompactNumberText(text) {
        if (!text) return null;
        const t = text.replace(/\u00A0/g, ' ').trim();
        const multipliers = { k: 1000, m: 1000000 };

        // 精确匹配:纯数字 / 带 k/m
        let match = t.replace(/,/g, '').match(/^(\d+(?:\.\d+)?)\s*([kKmM])?$/);
        if (!match) {
            // 文本中提取:如 "2.1k" 混在其它字符里
            match = t.match(/(\d+(?:\.\d+)?)\s*([kKmM])\b/);
        }

        if (match) {
            const value = parseFloat(match[1]);
            if (!isFinite(value)) return null;
            const unit = match[2]?.toLowerCase();
            const result = Math.round(value * (multipliers[unit] || 1));
            return isNaN(result) ? null : result;
        }

        // 回退:提取第一个整数(支持逗号)
        match = t.match(/(\d[\d,]*)/);
        return match ? parseNumberText(match[1]) : null;
    }

    // 通用缓存读取(当天有效)
    function getCache(key) {
        const cache = GM_getValue(key, null);
        return (cache && cache.date === new Date().toDateString()) ? cache.score : null;
    }

    // 通用缓存写入
    function setCache(key, score) {
        GM_setValue(key, { score, date: new Date().toDateString() });
    }

    // 从排行榜页面 DOM 获取当前积分
    function parseScoreFromElement(userEl) {
        if (!userEl) return null;
        const numberSpan = userEl.querySelector?.(SELECTORS.NUMBER_TITLE);
        if (numberSpan) {
            const fromTitle = parseNumberText(numberSpan.getAttribute('title'));
            if (fromTitle !== null) return fromTitle;
        }
        return parseCompactNumberText(userEl.textContent);
    }

    // 判断是否在排行榜页面
    function isLeaderboardPage() {
        return location.pathname === '/leaderboard' || location.pathname === '/leaderboard/';
    }

    function createAuthRequiredError(code = 'AUTH_REQUIRED', message = '未登录或无权限') {
        const authError = new Error(message);
        authError.code = code;
        return authError;
    }

    // 从响应中解析积分数据(公用逻辑)
    function parseBaseScoreFromResponse(res) {
        if (res.status === 401 || res.status === 403) {
            throw createAuthRequiredError('CREDIT_AUTH_REQUIRED');
        }
        if (res.status < 200 || res.status >= 300) {
            throw new Error(`请求失败: ${res.status}`);
        }
        const orders = JSON.parse(res.responseText).data?.orders || [];
        for (const item of orders) {
            if (item.remark?.includes('社区积分')) {
                const score = parseScore(item.remark);
                if (score !== null) return score;
            }
        }
        throw new Error('未找到积分记录');
    }

    // 构建 API 请求体
    function buildRequestBody() {
        const { startTime, endTime } = getTodayRange();
        return JSON.stringify({ page: 1, page_size: PAGE_SIZE, startTime, endTime, types: ["community"] });
    }

    // 方式一:通过 GM_xmlhttpRequest + withCredentials 获取积分(桌面端正常工作)
    function fetchBaseScoreViaGM() {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: TRANSACTIONS_API,
                headers: {
                    'Content-Type': 'application/json',
                    'Origin': 'https://credit.linux.do',
                    'Referer': 'https://credit.linux.do/'
                },
                data: buildRequestBody(),
                withCredentials: true,
                anonymous: false,
                onload: (res) => {
                    try {
                        resolve(parseBaseScoreFromResponse(res));
                    } catch (e) {
                        reject(e);
                    }
                },
                onerror: reject
            });
        });
    }

    // 方式二:同域 fetch(仅在 credit.linux.do 页面上有效,完全绕过 ITP)
    function fetchBaseScoreDirectly() {
        return fetch(TRANSACTIONS_API, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: buildRequestBody(),
            credentials: 'same-origin'
        }).then(async (response) => {
            const text = await response.text();
            return parseBaseScoreFromResponse({ status: response.status, responseText: text });
        });
    }

    // 判断是否在 credit.linux.do
    function isCreditPage() {
        return location.hostname === 'credit.linux.do';
    }

    // Safari / iOS WebKit 更容易遇到跨站 cookie 限制
    function hasWebKitCrossSiteCookieLimit() {
        const ua = navigator.userAgent;
        const isIOSWebKit = /iPhone|iPad|iPod/i.test(ua);
        const isSafari = /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS|Android/i.test(ua);
        return isIOSWebKit || isSafari;
    }

    // 判断页面是否已经落到登录/认证状态
    function isAuthRequiredPage(doc) {
        if (!doc) return false;

        try {
            const pathname = doc.location?.pathname || '';
            if (/\/(login|session|auth)\b/i.test(pathname)) {
                return true;
            }
        } catch (e) {}

        return !!(
            doc.querySelector('input[type="password"]') ||
            doc.querySelector('form[action*="/session"]') ||
            doc.querySelector('a[href*="/login"]') ||
            doc.querySelector('a[href*="/session"]')
        );
    }

    // 在 credit.linux.do 页面上显示提示 toast
    function showCreditPageToast(msg, isError) {
        const toast = document.createElement('div');
        toast.style.cssText = `
            position: fixed; bottom: 20px; right: 20px;
            background: ${isError ? '#fef2f2' : '#f0fdf4'};
            color: ${isError ? '#991b1b' : '#166534'};
            border: 1px solid ${isError ? '#fecaca' : '#bbf7d0'};
            padding: 10px 16px; border-radius: 8px; font-size: 13px;
            z-index: 9999; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
            transition: opacity 0.3s; max-width: 280px;
        `;
        toast.textContent = msg;
        document.body.appendChild(toast);
        setTimeout(() => {
            toast.style.opacity = '0';
            setTimeout(() => toast.remove(), 300);
        }, 3000);
    }

    // 在 credit.linux.do 页面上:同域获取基础积分并缓存
    function initOnCreditPage() {
        fetchBaseScoreDirectly().then(score => {
            setCache(STORAGE_KEY, score);
            GM_setValue(CURRENT_SCORE_KEY, null);
            console.log('[LDC] 在 credit.linux.do 上缓存了基础积分:', score);
            showCreditPageToast(`✅ 今日积分起点已同步 (${score}),回到 linux.do 刷新后就能看`, false);
        }).catch(e => {
            console.warn('[LDC] credit.linux.do 上获取积分失败:', e);
            showCreditPageToast('❌ 同步失败,请确认已经登录后再试', true);
        });
    }

    // 获取今日基础积分
    function fetchBaseScore() {
        return fetchBaseScoreViaGM()
            .catch(e => {
                if (e.code === 'CREDIT_AUTH_REQUIRED' && !isCreditPage() && hasWebKitCrossSiteCookieLimit()) {
                    const authError = new Error('未登录或无权限(请先去 credit.linux.do 同步一次积分)');
                    authError.code = 'CROSS_SITE_AUTH_REQUIRED';
                    throw authError;
                }
                throw e;
            });
    }

    // 等待元素出现(在指定文档中)
    function waitForElement(doc, selector, timeout = ELEMENT_WAIT_TIMEOUT) {
        return new Promise((resolve) => {
            const el = doc.querySelector(selector);
            if (el) return resolve(el);

            let resolved = false;
            const observer = new MutationObserver(() => {
                if (resolved) return;
                const el = doc.querySelector(selector);
                if (el) {
                    resolved = true;
                    observer.disconnect();
                    resolve(el);
                }
            });
            observer.observe(doc.body, { childList: true, subtree: true });

            setTimeout(() => {
                if (!resolved) {
                    resolved = true;
                    observer.disconnect();
                    resolve(null);
                }
            }, timeout);
        });
    }

    // 通过隐藏 iframe 获取排行榜积分
    function fetchScoreViaIframe() {
        return new Promise((resolve, reject) => {
            const iframe = document.createElement('iframe');
            iframe.style.cssText = 'position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;';
            iframe.src = LEADERBOARD_URL;

            let resolved = false;

            const finish = (success, value) => {
                if (resolved) return;
                resolved = true;
                if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
                success ? resolve(value) : reject(value);
            };

            iframe.onload = async () => {
                let iframeDoc;
                try {
                    iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
                } catch (e) {
                    return finish(false, new Error('无法访问 iframe 内容(跨域限制)'));
                }

                try {
                    await new Promise(r => setTimeout(r, DELAYS.IFRAME_INITIAL_WAIT));
                    const userEl = await waitForElement(iframeDoc, SELECTORS.USER_SCORE);
                    if (!userEl && isAuthRequiredPage(iframeDoc)) {
                        return finish(false, createAuthRequiredError('LINUX_DO_AUTH_REQUIRED'));
                    }
                    const score = parseScoreFromElement(userEl);
                    finish(score !== null, score ?? new Error('无法找到积分元素'));
                } catch (e) {
                    finish(false, e);
                }
            };

            iframe.onerror = () => finish(false, new Error('iframe 加载失败'));
            setTimeout(() => finish(false, new Error('获取积分超时')), IFRAME_TIMEOUT);
            document.body.appendChild(iframe);
        });
    }

    // 创建基础容器(含拖拽支持)
    function createContainer() {
        const container = document.createElement('div');
        container.id = 'linux-do-credit';
        container.style.cssText = CONTAINER_STYLE;

        // 恢复保存的位置
        const savedPos = GM_getValue(POSITION_KEY, null);
        if (savedPos) {
            container.style.right = 'auto';
            container.style.bottom = 'auto';
            container.style.left = savedPos.x + 'px';
            container.style.top = savedPos.y + 'px';
        }

        // 拖拽状态
        let isDragging = false;
        let hasMoved = false;
        let startX, startY, initialX, initialY;

        const onMove = (e) => {
            if (!isDragging) return;
            const touch = e.touches ? e.touches[0] : e;
            const dx = touch.clientX - startX;
            const dy = touch.clientY - startY;
            if (Math.abs(dx) > 5 || Math.abs(dy) > 5) hasMoved = true;
            if (hasMoved) {
                e.preventDefault();
                const rect = container.getBoundingClientRect();
                const newX = Math.max(0, Math.min(window.innerWidth - rect.width, initialX + dx));
                const newY = Math.max(0, Math.min(window.innerHeight - rect.height, initialY + dy));
                container.style.right = 'auto';
                container.style.bottom = 'auto';
                container.style.left = newX + 'px';
                container.style.top = newY + 'px';
            }
        };

        const onEnd = () => {
            if (!isDragging) return;
            isDragging = false;
            container.style.transition = '';
            if (hasMoved) {
                const rect = container.getBoundingClientRect();
                GM_setValue(POSITION_KEY, { x: rect.left, y: rect.top });
            }
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('touchmove', onMove);
            document.removeEventListener('mouseup', onEnd);
            document.removeEventListener('touchend', onEnd);
        };

        const onStart = (e) => {
            const touch = e.touches ? e.touches[0] : e;
            isDragging = true;
            hasMoved = false;
            startX = touch.clientX;
            startY = touch.clientY;
            const rect = container.getBoundingClientRect();
            initialX = rect.left;
            initialY = rect.top;
            container.style.transition = 'none';
            document.addEventListener('mousemove', onMove);
            document.addEventListener('touchmove', onMove, { passive: false });
            document.addEventListener('mouseup', onEnd);
            document.addEventListener('touchend', onEnd);
        };

        container.addEventListener('mousedown', onStart);
        container.addEventListener('touchstart', onStart, { passive: false });
        container._hasMoved = () => hasMoved;

        return container;
    }

    // 检测是否为移动端
    function isMobile() {
        return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
    }

    // 创建 LDC 链接元素
    function createLDCLink() {
        const link = document.createElement('a');
        link.href = 'https://credit.linux.do/';
        link.target = '_blank';
        link.style.cssText = 'color: #3b82f6; text-decoration: underline;';
        link.textContent = 'LDC';
        return link;
    }

    function createLinuxDoLink() {
        const link = document.createElement('a');
        link.href = 'https://linux.do/login';
        link.style.cssText = 'color: #3b82f6; text-decoration: underline;';
        link.textContent = 'linux.do';
        return link;
    }

    // 显示错误状态(带登录链接和重试按钮)
    function showErrorWithLogin(container, onRetry, error = null) {
        container.textContent = '';
        container.style.color = '#333';

        if (error?.code === 'CROSS_SITE_AUTH_REQUIRED') {
            container.appendChild(document.createTextNode('请先去 '));
            container.appendChild(createLDCLink());
            container.appendChild(document.createTextNode(' 同步一次积分后再刷新'));
        } else if (error?.code === 'CREDIT_AUTH_REQUIRED') {
            container.appendChild(document.createTextNode(isMobile() ? '请先去 ' : '请登录 '));
            container.appendChild(createLDCLink());
            if (isMobile()) {
                container.appendChild(document.createTextNode(' 登录一下,再回来刷新'));
            }
        } else if (error?.code === 'LINUX_DO_AUTH_REQUIRED') {
            container.appendChild(document.createTextNode(isMobile() ? '请先登录 ' : '请登录 '));
            container.appendChild(createLinuxDoLink());
            if (isMobile()) {
                container.appendChild(document.createTextNode(',再回来刷新'));
            }
        } else {
            container.appendChild(document.createTextNode('获取失败,请重试'));
        }

        if (onRetry) {
            const btn = document.createElement('span');
            btn.textContent = ' \u21BB';
            btn.title = '重新获取';
            btn.style.cssText = 'cursor: pointer; font-size: 16px; margin-left: 6px; color: #3b82f6; user-select: none;';
            btn.onclick = (e) => {
                e.stopPropagation();
                onRetry();
            };
            container.appendChild(btn);
        }
    }

    // 显示仅含错误提示的容器
    function showError(onRetry, error = null) {
        const container = createContainer();
        showErrorWithLogin(container, onRetry, error);
        document.body.appendChild(container);
        return () => {
            if (container.parentNode) container.parentNode.removeChild(container);
        };
    }

    // 创建 UI(统一排行榜页面和普通页面)
    function createUI(baseScore, onLeaderboard) {
        const container = createContainer();
        document.body.appendChild(container);

        let isLoading = false;
        let currentBaseScore = baseScore;

        // 更新显示
        const updateDisplay = (currentScore) => {
            const diff = currentScore - currentBaseScore;
            const sign = diff >= 0 ? '+' : '';
            const color = diff >= 0 ? '#22c55e' : '#ef4444';
            container.textContent = '';
            container.style.color = '#333';
            container.appendChild(document.createTextNode('今日: '));
            const span = document.createElement('span');
            span.style.cssText = `font-weight: bold; color: ${color};`;
            span.textContent = `${sign}${diff}`;
            container.appendChild(span);
        };

        // 获取当前积分
        const fetchCurrentScore = async () => {
            if (onLeaderboard) {
                const userEl = await waitForElement(document, SELECTORS.USER_SCORE);
                if (!userEl && isAuthRequiredPage(document)) {
                    throw createAuthRequiredError('LINUX_DO_AUTH_REQUIRED');
                }
                return parseScoreFromElement(userEl);
            }
            return await fetchScoreViaIframe();
        };

        // 显示带重试按钮的警告
        const showWarning = (msg) => {
            container.textContent = '';
            container.style.color = '#333';
            container.appendChild(document.createTextNode(msg));
            const btn = document.createElement('span');
            btn.textContent = ' \u21BB';
            btn.title = '重新获取';
            btn.style.cssText = 'cursor: pointer; font-size: 16px; margin-left: 6px; color: #3b82f6; user-select: none;';
            btn.onclick = (e) => { e.stopPropagation(); doFetch(); };
            container.appendChild(btn);
        };

        // 获取积分并更新 UI
        const doFetch = async () => {
            if (isLoading) return;
            isLoading = true;
            container.textContent = '获取中...';
            container.style.color = '#999';

            try {
                // 跨天后刷新基础积分
                const cachedBase = getCache(STORAGE_KEY);
                if (cachedBase === null) {
                    currentBaseScore = await fetchBaseScore();
                    setCache(STORAGE_KEY, currentBaseScore);
                    GM_setValue(CURRENT_SCORE_KEY, null);
                } else {
                    currentBaseScore = cachedBase;
                }
            } catch (e) {
                console.error('获取基础积分失败:', e);
                showErrorWithLogin(container, doFetch, e);
                isLoading = false;
                return;
            }

            try {
                const currentScore = await fetchCurrentScore();
                if (currentScore !== null) {
                    setCache(CURRENT_SCORE_KEY, currentScore);
                    updateDisplay(currentScore);
                } else {
                    showWarning('积分获取失败');
                }
            } catch (e) {
                console.error('获取当前积分失败:', e);
                if (e?.code === 'LINUX_DO_AUTH_REQUIRED' || e?.code === 'CROSS_SITE_AUTH_REQUIRED' || e?.code === 'CREDIT_AUTH_REQUIRED') {
                    showErrorWithLogin(container, doFetch, e);
                } else {
                    showWarning('积分获取失败');
                }
            }

            isLoading = false;
        };

        let clickTimeout = null;

        // 点击刷新(排除拖拽)
        container.onclick = (e) => {
            if (container._hasMoved()) return;
            if (e.target.tagName === 'A') return;
            if (clickTimeout) clearTimeout(clickTimeout);
            clickTimeout = setTimeout(() => {
                doFetch();
                clickTimeout = null;
            }, DELAYS.CLICK_DEBOUNCE);
        };

        // 双击清理缓存
        container.ondblclick = () => {
            if (clickTimeout) clearTimeout(clickTimeout);
            GM_setValue(STORAGE_KEY, null);
            GM_setValue(CURRENT_SCORE_KEY, null);
            GM_setValue(POSITION_KEY, null);
            location.reload();
        };

        // 初始显示:有缓存先展示
        const cachedCurrentScore = getCache(CURRENT_SCORE_KEY);
        if (cachedCurrentScore !== null) {
            updateDisplay(cachedCurrentScore);
        }

        // 排行榜页面或无缓存时自动获取一次
        if (onLeaderboard || cachedCurrentScore === null) {
            doFetch();
        }

        // 定时自动刷新
        const intervalId = setInterval(doFetch, AUTO_REFRESH_INTERVAL);

        return () => {
            if (clickTimeout) clearTimeout(clickTimeout);
            clearInterval(intervalId);
            if (container.parentNode) container.parentNode.removeChild(container);
        };
    }

    // 当前实例的清理函数
    let currentCleanup = null;
    let initCounter = 0;

    // 初始化(可重复调用,自动清理上一次实例)
    async function init() {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', init);
            return;
        }

        // 如果在 credit.linux.do 页面,仅缓存积分数据,不显示 UI
        if (isCreditPage()) {
            initOnCreditPage();
            return;
        }

        const currentInitId = ++initCounter;

        if (currentCleanup) {
            currentCleanup();
            currentCleanup = null;
        }

        // 获取基础积分(优先缓存)
        let baseScore = getCache(STORAGE_KEY);

        if (baseScore === null) {
            try {
                baseScore = await fetchBaseScore();
                setCache(STORAGE_KEY, baseScore);
            } catch (e) {
                console.error('获取积分失败:', e);
                if (currentInitId !== initCounter) return;
                currentCleanup = showError(init, e);
                return;
            }
        }

        if (currentInitId !== initCounter) return;
        currentCleanup = createUI(baseScore, isLeaderboardPage());
    }

    // 监听 SPA 导航(仅 linux.do)
    if (!isCreditPage()) {
        let lastPath = location.pathname;
        const onNavigate = () => {
            if (location.pathname !== lastPath) {
                lastPath = location.pathname;
                init();
            }
        };

        window.addEventListener('popstate', onNavigate);

        const origPushState = history.pushState;
        const origReplaceState = history.replaceState;
        history.pushState = function(...args) {
            origPushState.apply(this, args);
            onNavigate();
        };
        history.replaceState = function(...args) {
            origReplaceState.apply(this, args);
            onNavigate();
        };
    }

    init();
})();