Greasy Fork is available in English.

Suwayomi Comick Tracker

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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);

})();