Suwayomi Comick Tracker

Track Comick.dev chapter counts on Suwayomi. Fully customizable.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Suwayomi Comick Tracker
// @namespace    http://tampermonkey.net/
// @version      18.0
// @description  Track Comick.dev chapter counts on Suwayomi. Fully customizable.
// @license      MIT
//
// ==============================================================================
// 📖 DOCUMENTATION & CONFIGURATION 📖
// ==============================================================================
//
// 1. HOW TO ADD YOUR URL (Important!)
//    Tampermonkey needs to know where to run this script.
//    Look at the lines starting with "@match" below.
//    - If you use localhost, keep the default lines.
//    - If you use a custom domain (e.g., mysuwayomi.com), ADD a new line like:
//      // @match https://mysuwayomi.com/*
//
// 2. TIMINGS (Performance vs. Speed)
//    - CACHE_TIME: How long to remember a chapter count before checking Comick again.
//      Default is 24 hours. Set to (1000 * 60 * 60 * 2) for 2 hours.
//    - REQUEST_DELAY: Time to wait between fetching each manga (in milliseconds).
//      WARNING: Do not set lower than 1000ms (1 second) or Comick might ban your IP.
//    - SCAN_INTERVAL: How often the script checks the page for new images (in milliseconds).
//
// 3. BUTTON POSITIONS (Visuals)
//    You can change 'bottom', 'right', 'gap', and 'direction' in the variables below.
//
// ==============================================================================
//
// @match        http://localhost:4567/*
// @match        http://127.0.0.1:4567/*
// @match        https://YOUR-SUWAYOMI-DOMAIN.com/*
//
// @icon         https://comick.dev/favicon.ico
// @connect      api.comick.dev
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @grant        GM_openInTab
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 🛠️ SETTINGS AREA (EDIT VALUES HERE) 🛠️
    // ==========================================

    // --- ⏱️ TIMING SETTINGS ---
    const CACHE_TIME = 1000 * 60 * 60 * 24;  // 24 Hours (in milliseconds)
    const REQUEST_DELAY = 1500;              // 1.5 Seconds delay between API requests
    const SCAN_INTERVAL = 1000;              // 2.5 Seconds check for new images on screen

    // --- 🎨 BUTTON STYLE: LIBRARY PAGE ---
    const LIB_STYLE = {
        bottom: '20px',      // Distance from bottom of screen
        right: '20px',       // Distance from right of screen
        gap: '10px',         // Space between buttons
        direction: 'column'  // 'column' (Vertical) or 'row' (Horizontal)
    };

    // --- 🎨 BUTTON STYLE: MANGA DETAILS PAGE ---
    const MANGA_STYLE = {
        bottom: '90px',      // Higher to avoid blocking the "Resume" button
        right: '60px',       // Moved left to avoid blocking the 3-dot menu
        gap: '8px',          // Tighter spacing
        direction: 'column'  // Vertical stack
    };

    // ==========================================
    // ⛔ CONFIGURATION END ⛔
    // ==========================================

    const COMICK_API_URL = "https://api.comick.dev/v1.0/search";
    const OVERRIDE_PREFIX = "title_override_";

    let processingQueue = false;
    let queue = [];

    // --- UI: BUTTONS ---
    function manageButtonVisibility() {
        const url = window.location.href;

        const isLibrary = url.includes('/library');
        const isMangaDetails = url.includes('/manga/') && !url.includes('/chapter/');
        const isAllowedPage = isLibrary || isMangaDetails;

        // 1. Get or Create Container
        let container = document.getElementById('comick-btn-container');
        if (!container) {
            container = document.createElement('div');
            container.id = 'comick-btn-container';
            Object.assign(container.style, {
                position: 'fixed',
                zIndex: '10000',
                display: 'flex',
                alignItems: 'flex-end',
                transition: 'bottom 0.3s, right 0.3s'
            });
            document.body.appendChild(container);
        }

        // 2. Apply Styles Based on Location
        if (isAllowedPage) {
            container.style.display = 'flex';

            if (isMangaDetails) {
                container.style.bottom = MANGA_STYLE.bottom;
                container.style.right = MANGA_STYLE.right;
                container.style.gap = MANGA_STYLE.gap;
                container.style.flexDirection = MANGA_STYLE.direction;
            } else {
                container.style.bottom = LIB_STYLE.bottom;
                container.style.right = LIB_STYLE.right;
                container.style.gap = LIB_STYLE.gap;
                container.style.flexDirection = LIB_STYLE.direction;
            }

            // 3. Manage Individual Buttons

            // --- Link Button ---
            let linkBtn = document.getElementById('comick-link-btn');
            if (!linkBtn) {
                linkBtn = createButton('comick-link-btn', '↗ Comick', '#ff6740', null);
                container.appendChild(linkBtn);
            }

            if (isMangaDetails) {
                const items = findDetailsCover();
                if (items.length > 0) {
                    const cached = GM_getValue(items[0].title);
                    if (cached && cached.slug) {
                        linkBtn.style.display = 'block';
                        linkBtn.onclick = () => window.open(`https://comick.dev/comic/${cached.slug}`, '_blank');
                    } else {
                        linkBtn.style.display = 'none';
                    }
                } else {
                    linkBtn.style.display = 'none';
                }
            } else {
                linkBtn.style.display = 'none';
            }

            // --- Edit Button ---
            let editBtn = document.getElementById('comick-edit-btn');
            if (!editBtn) {
                editBtn = createButton('comick-edit-btn', '✎ Edit Search', '#4CAF50', handleEditClick);
                container.appendChild(editBtn);
            }
            editBtn.style.display = isMangaDetails ? 'block' : 'none';

            // --- Refresh Button ---
            let refreshBtn = document.getElementById('comick-refresh-btn');
            if (!refreshBtn) {
                refreshBtn = createButton('comick-refresh-btn', '↻ Refresh', '#2196F3', handleRefreshClick);
                container.appendChild(refreshBtn);
            }
            refreshBtn.style.display = 'block';
            refreshBtn.innerHTML = isMangaDetails ? '↻ Refresh Manga' : '↻ Refresh All';

        } else {
            container.style.display = 'none';
        }
    }

    function createButton(id, text, color, handler) {
        const btn = document.createElement('button');
        btn.id = id;
        btn.innerHTML = text;

        Object.assign(btn.style, {
            padding: '6px 12px',
            backgroundColor: color,
            color: 'white',
            border: 'none',
            borderRadius: '20px',
            boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
            cursor: 'pointer',
            fontWeight: 'bold',
            fontSize: '12px',
            transition: 'transform 0.2s, opacity 0.2s',
            opacity: '0.9',
            whiteSpace: 'nowrap',
            width: 'fit-content'
        });

        btn.onmouseover = () => {
            btn.style.transform = 'scale(1.05)';
            btn.style.opacity = '1';
        };
        btn.onmouseout = () => {
            btn.style.transform = 'scale(1)';
            btn.style.opacity = '0.9';
        };
        if (handler) btn.onclick = handler;

        return btn;
    }

    // --- HANDLERS ---
    function handleEditClick() {
        const items = findDetailsCover();
        if (items.length === 0) return alert("Wait for the page to load fully.");

        const originalTitle = items[0].title;
        const currentOverride = GM_getValue(OVERRIDE_PREFIX + originalTitle, "");

        const newTitle = prompt(
            `Enter the EXACT title to search on Comick for:\n"${originalTitle}"\n\n(Leave empty to reset to default)`,
            currentOverride || originalTitle
        );

        if (newTitle !== null) {
            if (newTitle.trim() === "" || newTitle.trim() === originalTitle) {
                GM_deleteValue(OVERRIDE_PREFIX + originalTitle);
                alert("Search title reset to default.");
            } else {
                GM_setValue(OVERRIDE_PREFIX + originalTitle, newTitle.trim());
            }
            singleReset(originalTitle);
        }
    }

    function handleRefreshClick() {
        const url = window.location.href;
        const isMangaDetails = url.includes('/manga/') && !url.includes('/chapter/');

        if (isMangaDetails) {
            const items = findDetailsCover();
            if (items.length > 0) {
                const title = items[0].title;
                const override = GM_getValue(OVERRIDE_PREFIX + title);
                const msg = override ? `Refresh "${title}" using custom search "${override}"?` : `Refresh chapter count for "${title}"?`;

                if (confirm(msg)) singleReset(title);
            }
        } else {
            if (confirm("Clear cache and re-scan ALL manga in library?")) fullReset();
        }
    }

    function updateButtonStatus(text, color) {
        const btn = document.getElementById('comick-refresh-btn');
        if (btn) {
            btn.innerHTML = text;
            if (color) btn.style.backgroundColor = color;
        }
    }

    // --- LOGIC ---
    function singleReset(title) {
        GM_deleteValue(title);
        const items = findDetailsCover();
        if (items.length > 0) {
            const container = items[0].element;
            const badge = container.querySelector('.comick-tracker-badge');
            if (badge) badge.remove();
            delete container.dataset.comickProcessed;
        }
        scan();
    }

    function fullReset() {
        const keys = GM_listValues();
        keys.forEach(key => {
            if (!key.startsWith(OVERRIDE_PREFIX)) {
                GM_deleteValue(key);
            }
        });

        document.querySelectorAll('.comick-tracker-badge').forEach(el => el.remove());
        document.querySelectorAll('[data-comick-processed]').forEach(el => delete el.dataset.comickProcessed);
        queue = [];
        processingQueue = false;
        scan();
    }

    function findLibraryCards() {
        const images = document.querySelectorAll('img');
        const cards = [];
        images.forEach(img => {
            if (img.width < 50 || img.height < 50) return;
            const title = img.alt || img.title;
            if (!title) return;
            const container = img.parentElement;
            if (container.dataset.comickProcessed) return;
            cards.push({ element: container, title: title, type: 'library' });
        });
        return cards;
    }

    function findDetailsCover() {
        const images = Array.from(document.querySelectorAll('img'));
        const potentialCovers = images.filter(img => img.width > 150 && img.height > 200 && !img.closest('button'));
        if (potentialCovers.length === 0) return [];

        const mainCover = potentialCovers[0];
        const container = mainCover.parentElement;

        let title = document.title.split(' - ')[0].trim();
        if (title === "Suwayomi" || title === "Library") title = mainCover.alt;
        if (!title) return [];

        return [{ element: container, title: title, type: 'details' }];
    }

    function createBadge(number, type) {
        const badge = document.createElement('div');
        badge.className = 'comick-tracker-badge';
        badge.innerText = number;

        const style = {
            position: 'absolute',
            backgroundColor: number === 'Err' ? '#757575' : '#d32f2f',
            color: 'white',
            borderRadius: '4px',
            padding: '2px 6px',
            fontWeight: 'bold',
            zIndex: '9999',
            boxShadow: '0px 2px 4px rgba(0,0,0,0.8)',
            pointerEvents: 'none',
            border: '1px solid white',
            fontFamily: 'sans-serif'
        };

        if (type === 'details') {
            style.fontSize = '14px';
            style.padding = '4px 10px';
            style.top = '10px';
            style.left = '10px';
        } else {
            style.fontSize = '11px';
            style.top = '30px';
            style.left = '5px';
        }
        Object.assign(badge.style, style);
        return badge;
    }

    function fetchComickData(originalTitle, callback) {
        const override = GM_getValue(OVERRIDE_PREFIX + originalTitle);
        const searchTitle = override || originalTitle;
        const query = encodeURIComponent(searchTitle);

        GM_xmlhttpRequest({
            method: "GET",
            url: `${COMICK_API_URL}?q=${query}&limit=8`,
            timeout: 5000,
            onload: function(response) {
                if (response.status === 200) {
                    try {
                        const data = JSON.parse(response.responseText);
                        if (Array.isArray(data) && data.length > 0) {

                            const targetTitle = searchTitle.toLowerCase();
                            const relevantMatches = data.filter(item => {
                                const itemTitle = item.title.toLowerCase();
                                return itemTitle.includes(targetTitle) || targetTitle.includes(itemTitle);
                            });

                            const candidates = relevantMatches.length > 0 ? relevantMatches : data;
                            let maxChapter = 0;
                            let bestMatch = null;
                            let bestSlug = null;

                            candidates.forEach(comic => {
                                const currentChap = parseFloat(comic.last_chapter);
                                if (!isNaN(currentChap) && currentChap > maxChapter) {
                                    maxChapter = currentChap;
                                    bestMatch = comic.last_chapter;
                                    bestSlug = comic.slug;
                                }
                            });

                            callback({ count: bestMatch || "?", slug: bestSlug });
                        } else {
                            callback({ count: "N/A", slug: null });
                        }
                    } catch (e) {
                        callback({ count: "Err", slug: null });
                    }
                } else {
                    callback({ count: "Err", slug: null });
                }
            },
            onerror: function(err) {
                callback({ count: "Err", slug: null });
            }
        });
    }

    function processQueue() {
        if (queue.length === 0) {
            processingQueue = false;
            const url = window.location.href;
            const isMangaDetails = url.includes('/manga/') && !url.includes('/chapter/');
            updateButtonStatus(isMangaDetails ? '↻ Refresh Manga' : '↻ Refresh All', '#2196F3');
            manageButtonVisibility();
            return;
        }

        processingQueue = true;
        updateButtonStatus(`Scanning... (${queue.length})`, '#FF9800');

        const { element, title, type } = queue.shift();
        const cached = GM_getValue(title);
        const now = Date.now();

        if (cached && (now - cached.timestamp < CACHE_TIME)) {
            const count = (typeof cached.count === 'object') ? cached.count.count : cached.count;
            updateCardUI(element, count, type);
            requestAnimationFrame(processQueue);
        } else {
            fetchComickData(title, (result) => {
                if (result.count !== "Err") {
                    GM_setValue(title, { count: result.count, slug: result.slug, timestamp: now });
                }
                updateCardUI(element, result.count, type);
                setTimeout(processQueue, REQUEST_DELAY);
            });
        }
    }

    function updateCardUI(container, count, type) {
        if (!container) return;
        const style = window.getComputedStyle(container);
        if (style.position === 'static') container.style.position = 'relative';

        const oldBadge = container.querySelector('.comick-tracker-badge');
        if (oldBadge) oldBadge.remove();

        const badge = createBadge(count, type);
        container.appendChild(badge);
    }

    function scan() {
        manageButtonVisibility();
        const url = window.location.href;
        let newItems = [];

        const isLibrary = url.includes('/library');
        const isMangaDetails = url.includes('/manga/') && !url.includes('/chapter/');

        if (isLibrary) newItems = findLibraryCards();
        else if (isMangaDetails) newItems = findDetailsCover();

        if (newItems && newItems.length > 0) {
            newItems.forEach(item => {
                if (isMangaDetails || !item.element.dataset.comickProcessed) {
                    item.element.dataset.comickProcessed = "true";
                    queue.push(item);
                }
            });
        }

        if (!processingQueue) processQueue();
    }

    // --- INIT ---
    setTimeout(scan, 1500);
    setInterval(scan, SCAN_INTERVAL);

})();